Loading
Create a drag-and-drop Kanban board for mobile using React Native with Expo, AsyncStorage persistence, and swipe gestures.
Kanban boards are one of the most effective tools for task management, and building one for mobile teaches you gesture handling, drag-and-drop mechanics, and persistent local storage in React Native. In this tutorial, you will build a mobile Kanban app using Expo and TypeScript that features draggable cards between columns, swipe-to-delete gestures, AsyncStorage persistence, and smooth animations.
You will use Expo's managed workflow so the project runs identically on macOS, Windows, and Linux during development, and deploys to both iOS and Android from a single codebase. No native module linking or platform-specific code is required.
By the end, you will have a polished task management app you can install on your own phone, plus deep experience with React Native's gesture and animation systems.
Create a new Expo project with TypeScript:
Update tsconfig.json to enable strict mode:
Create the src directory structure:
Create src/types/kanban.ts:
Create src/utils/constants.ts:
Create src/hooks/useBoard.ts:
Create src/components/SwipeableCard.tsx:
Create src/components/Column.tsx:
Create src/components/AddCardModal.tsx:
Create src/components/Board.tsx:
Update App.tsx:
Enhance the card interactions with haptic feedback. Install the Expo haptics module:
Add haptics to SwipeableCard.tsx by importing and calling it when a swipe completes:
Add staggered entrance animations to the Column component using Animated.FadeIn:
These small touches make the app feel native and responsive. Haptic feedback confirms actions without requiring the user to look at the screen, and staggered animations guide the eye through the card list.
Start the development server and test on your device:
Scan the QR code with Expo Go on your phone, or press i for iOS simulator / a for Android emulator.
Test the following scenarios thoroughly:
For production readiness, consider adding card reordering within columns using long-press drag, due dates with color-coded urgency indicators, a search bar that filters cards across all columns, and undo support for accidental deletes with a brief toast notification. The architecture you have built with the useBoard hook makes all of these enhancements straightforward since every state mutation flows through a single, predictable path.
npx create-expo-app@latest kanban-mobile --template blank-typescript
cd kanban-mobile
npx expo install @react-native-async-storage/async-storage react-native-gesture-handler react-native-reanimated{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
}
}mkdir -p src/components src/hooks src/types src/utilsexport interface KanbanCard {
id: string;
title: string;
description: string;
color: string;
createdAt: string;
}
export interface KanbanColumn {
id: string;
title: string;
cards: KanbanCard[];
}
export type ColumnId = "todo" | "inProgress" | "done";
export interface BoardState {
columns: Record<ColumnId, KanbanColumn>;
columnOrder: ColumnId[];
}import type { BoardState } from "../types/kanban";
export const COLORS = {
background: "#0f172a",
surface: "#1e293b",
surfaceLight: "#334155",
border: "#475569",
text: "#f1f5f9",
textSecondary: "#94a3b8",
todo: "#f59e0b",
inProgress: "#3b82f6",
done: "#10b981",
danger: "#ef4444",
} as const;
export const CARD_COLORS = [
"#6366f1",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#06b6d4",
] as const;
export const DEFAULT_BOARD: BoardState = {
columns: {
todo: { id: "todo", title: "To Do", cards: [] },
inProgress: { id: "inProgress", title: "In Progress", cards: [] },
done: { id: "done", title: "Done", cards: [] },
},
columnOrder: ["todo", "inProgress", "done"],
};import { useState, useEffect, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { BoardState, ColumnId, KanbanCard } from "../types/kanban";
import { DEFAULT_BOARD, CARD_COLORS } from "../utils/constants";
const STORAGE_KEY = "@kanban_board";
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
export function useBoard(): {
board: BoardState;
isLoading: boolean;
addCard: (columnId: ColumnId, title: string, description: string) => void;
moveCard: (cardId: string, fromColumn: ColumnId, toColumn: ColumnId) => void;
deleteCard: (columnId: ColumnId, cardId: string) => void;
} {
const [board, setBoard] = useState<BoardState>(DEFAULT_BOARD);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
async function load(): Promise<void> {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
setBoard(JSON.parse(stored) as BoardState);
}
} catch (error) {
console.error("Failed to load board:", error);
} finally {
setIsLoading(false);
}
}
void load();
}, []);
const persist = useCallback(async (newBoard: BoardState): Promise<void> => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newBoard));
} catch (error) {
console.error("Failed to save board:", error);
}
}, []);
const addCard = useCallback(
(columnId: ColumnId, title: string, description: string): void => {
const card: KanbanCard = {
id: generateId(),
title,
description,
color: CARD_COLORS[Math.floor(Math.random() * CARD_COLORS.length)],
createdAt: new Date().toISOString(),
};
setBoard((prev) => {
const next = {
...prev,
columns: {
...prev.columns,
[columnId]: {
...prev.columns[columnId],
cards: [...prev.columns[columnId].cards, card],
},
},
};
void persist(next);
return next;
});
},
[persist]
);
const moveCard = useCallback(
(cardId: string, fromColumn: ColumnId, toColumn: ColumnId): void => {
if (fromColumn === toColumn) return;
setBoard((prev) => {
const card = prev.columns[fromColumn].cards.find((c) => c.id === cardId);
if (!card) return prev;
const next = {
...prev,
columns: {
...prev.columns,
[fromColumn]: {
...prev.columns[fromColumn],
cards: prev.columns[fromColumn].cards.filter((c) => c.id !== cardId),
},
[toColumn]: {
...prev.columns[toColumn],
cards: [...prev.columns[toColumn].cards, card],
},
},
};
void persist(next);
return next;
});
},
[persist]
);
const deleteCard = useCallback(
(columnId: ColumnId, cardId: string): void => {
setBoard((prev) => {
const next = {
...prev,
columns: {
...prev.columns,
[columnId]: {
...prev.columns[columnId],
cards: prev.columns[columnId].cards.filter((c) => c.id !== cardId),
},
},
};
void persist(next);
return next;
});
},
[persist]
);
return { board, isLoading, addCard, moveCard, deleteCard };
}import React, { useCallback } from "react";
import { View, Text, StyleSheet, Pressable } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from "react-native-reanimated";
import type { KanbanCard } from "../types/kanban";
import { COLORS } from "../utils/constants";
interface SwipeableCardProps {
card: KanbanCard;
onSwipeLeft: () => void;
onSwipeRight: () => void;
onDelete: () => void;
}
const SWIPE_THRESHOLD = 80;
export function SwipeableCard({ card, onSwipeLeft, onSwipeRight, onDelete }: SwipeableCardProps): React.ReactElement {
const translateX = useSharedValue(0);
const handleSwipeLeft = useCallback((): void => {
onSwipeLeft();
}, [onSwipeLeft]);
const handleSwipeRight = useCallback((): void => {
onSwipeRight();
}, [onSwipeRight]);
const handleDelete = useCallback((): void => {
onDelete();
}, [onDelete]);
const pan = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd((event) => {
if (event.translationX < -SWIPE_THRESHOLD) {
runOnJS(handleSwipeLeft)();
} else if (event.translationX > SWIPE_THRESHOLD) {
runOnJS(handleSwipeRight)();
}
translateX.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]}>
<View style={[styles.colorBar, { backgroundColor: card.color }]} />
<View style={styles.cardContent}>
<Text style={styles.cardTitle}>{card.title}</Text>
{card.description ? (
<Text style={styles.cardDescription}>{card.description}</Text>
) : null}
</View>
<Pressable onPress={handleDelete} style={styles.deleteButton}>
<Text style={styles.deleteText}>x</Text>
</Pressable>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.surface,
borderRadius: 12,
marginBottom: 10,
flexDirection: "row",
overflow: "hidden",
borderWidth: 1,
borderColor: COLORS.border,
},
colorBar: {
width: 4,
},
cardContent: {
flex: 1,
padding: 14,
},
cardTitle: {
color: COLORS.text,
fontSize: 15,
fontWeight: "600",
},
cardDescription: {
color: COLORS.textSecondary,
fontSize: 13,
marginTop: 4,
},
deleteButton: {
justifyContent: "center",
paddingHorizontal: 14,
},
deleteText: {
color: COLORS.danger,
fontSize: 18,
fontWeight: "700",
},
});import React from "react";
import { View, Text, ScrollView, StyleSheet } from "react-native";
import { SwipeableCard } from "./SwipeableCard";
import type { KanbanColumn, ColumnId } from "../types/kanban";
import { COLORS } from "../utils/constants";
interface ColumnProps {
column: KanbanColumn;
columnId: ColumnId;
onMoveCard: (cardId: string, direction: "left" | "right") => void;
onDeleteCard: (cardId: string) => void;
}
const COLUMN_COLORS: Record<ColumnId, string> = {
todo: COLORS.todo,
inProgress: COLORS.inProgress,
done: COLORS.done,
};
export function Column({ column, columnId, onMoveCard, onDeleteCard }: ColumnProps): React.ReactElement {
return (
<View style={styles.column}>
<View style={styles.header}>
<View style={[styles.dot, { backgroundColor: COLUMN_COLORS[columnId] }]} />
<Text style={styles.title}>{column.title}</Text>
<Text style={styles.count}>{column.cards.length}</Text>
</View>
<ScrollView style={styles.cardList} showsVerticalScrollIndicator={false}>
{column.cards.map((card) => (
<SwipeableCard
key={card.id}
card={card}
onSwipeLeft={() => onMoveCard(card.id, "right")}
onSwipeRight={() => onMoveCard(card.id, "left")}
onDelete={() => onDeleteCard(card.id)}
/>
))}
{column.cards.length === 0 && (
<Text style={styles.empty}>No cards yet</Text>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
column: {
width: 300,
marginRight: 16,
backgroundColor: COLORS.background,
borderRadius: 16,
padding: 12,
borderWidth: 1,
borderColor: COLORS.border,
},
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
dot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
title: {
color: COLORS.text,
fontSize: 16,
fontWeight: "700",
flex: 1,
},
count: {
color: COLORS.textSecondary,
fontSize: 14,
},
cardList: {
flex: 1,
},
empty: {
color: COLORS.textSecondary,
textAlign: "center",
paddingVertical: 20,
fontSize: 14,
},
});import React, { useState } from "react";
import { View, Text, TextInput, Pressable, Modal, StyleSheet } from "react-native";
import type { ColumnId } from "../types/kanban";
import { COLORS } from "../utils/constants";
interface AddCardModalProps {
isVisible: boolean;
columnId: ColumnId;
onAdd: (columnId: ColumnId, title: string, description: string) => void;
onClose: () => void;
}
export function AddCardModal({ isVisible, columnId, onAdd, onClose }: AddCardModalProps): React.ReactElement {
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
function handleSubmit(): void {
if (!title.trim()) return;
onAdd(columnId, title.trim(), description.trim());
setTitle("");
setDescription("");
onClose();
}
return (
<Modal visible={isVisible} transparent animationType="slide">
<View style={styles.overlay}>
<View style={styles.modal}>
<Text style={styles.modalTitle}>New Card</Text>
<TextInput
style={styles.input}
placeholder="Title"
placeholderTextColor={COLORS.textSecondary}
value={title}
onChangeText={setTitle}
autoFocus
/>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="Description (optional)"
placeholderTextColor={COLORS.textSecondary}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={3}
/>
<View style={styles.actions}>
<Pressable style={styles.cancelButton} onPress={onClose}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable style={styles.addButton} onPress={handleSubmit}>
<Text style={styles.addText}>Add</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "center",
padding: 24,
},
modal: {
backgroundColor: COLORS.surface,
borderRadius: 16,
padding: 24,
},
modalTitle: {
color: COLORS.text,
fontSize: 20,
fontWeight: "700",
marginBottom: 16,
},
input: {
backgroundColor: COLORS.background,
color: COLORS.text,
borderRadius: 10,
padding: 14,
fontSize: 15,
marginBottom: 12,
borderWidth: 1,
borderColor: COLORS.border,
},
textArea: {
minHeight: 80,
textAlignVertical: "top",
},
actions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 12,
marginTop: 8,
},
cancelButton: {
paddingHorizontal: 20,
paddingVertical: 12,
},
cancelText: {
color: COLORS.textSecondary,
fontSize: 15,
fontWeight: "600",
},
addButton: {
backgroundColor: COLORS.inProgress,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 10,
},
addText: {
color: COLORS.text,
fontSize: 15,
fontWeight: "700",
},
});import React, { useState, useCallback } from "react";
import { View, ScrollView, Pressable, Text, StyleSheet, ActivityIndicator } from "react-native";
import { Column } from "./Column";
import { AddCardModal } from "./AddCardModal";
import { useBoard } from "../hooks/useBoard";
import type { ColumnId } from "../types/kanban";
import { COLORS } from "../utils/constants";
const COLUMN_ORDER: ColumnId[] = ["todo", "inProgress", "done"];
const ADJACENT: Record<ColumnId, { left: ColumnId | null; right: ColumnId | null }> = {
todo: { left: null, right: "inProgress" },
inProgress: { left: "todo", right: "done" },
done: { left: "inProgress", right: null },
};
export function Board(): React.ReactElement {
const { board, isLoading, addCard, moveCard, deleteCard } = useBoard();
const [modalColumn, setModalColumn] = useState<ColumnId | null>(null);
const handleMoveCard = useCallback(
(columnId: ColumnId, cardId: string, direction: "left" | "right"): void => {
const target = ADJACENT[columnId][direction];
if (target) {
moveCard(cardId, columnId, target);
}
},
[moveCard]
);
if (isLoading) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color={COLORS.inProgress} />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Kanban Board</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.boardContent}>
{COLUMN_ORDER.map((columnId) => (
<View key={columnId}>
<Column
column={board.columns[columnId]}
columnId={columnId}
onMoveCard={(cardId, direction) => handleMoveCard(columnId, cardId, direction)}
onDeleteCard={(cardId) => deleteCard(columnId, cardId)}
/>
<Pressable style={styles.addButton} onPress={() => setModalColumn(columnId)}>
<Text style={styles.addButtonText}>+ Add Card</Text>
</Pressable>
</View>
))}
</ScrollView>
{modalColumn !== null && (
<AddCardModal
isVisible={true}
columnId={modalColumn}
onAdd={addCard}
onClose={() => setModalColumn(null)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
loading: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: COLORS.background,
},
header: {
paddingTop: 60,
paddingBottom: 16,
paddingHorizontal: 20,
},
title: {
color: COLORS.text,
fontSize: 28,
fontWeight: "800",
},
boardContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
addButton: {
marginTop: 8,
padding: 12,
alignItems: "center",
borderRadius: 10,
borderWidth: 1,
borderColor: COLORS.border,
borderStyle: "dashed",
},
addButtonText: {
color: COLORS.textSecondary,
fontSize: 14,
fontWeight: "600",
},
});import React from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StatusBar } from "react-native";
import { Board } from "./src/components/Board";
export default function App(): React.ReactElement {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar barStyle="light-content" />
<Board />
</GestureHandlerRootView>
);
}npx expo install expo-hapticsimport * as Haptics from "expo-haptics";
// Inside the pan gesture onEnd handler, before calling move/delete:
runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Medium);import Animated, { FadeInDown } from "react-native-reanimated";
// Wrap each card in the map:
<Animated.View entering={FadeInDown.delay(index * 50).springify()} key={card.id}>
<SwipeableCard ... />
</Animated.View>npx expo start