Free
Open source • Copy & pasteTask Flow
Kanban productivity kit
Full-featured Kanban board with drag-and-drop, priority filtering, and haptic feedback.
1 screens
Expo Router + Reanimated + Gesture Handler + Bottom Sheet
Motion-ready
kanbandrag-dropproductivity
Source code
Explore the component structure. Copy directly into any Expo app.
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { Stack } from 'expo-router';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { LayoutRectangle, ViewStyle } from 'react-native';
import { ScrollView, View } from 'react-native';
import { useColorScheme } from 'react-native-css-interop';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { FocusAwareStatusBar, Pressable, Text } from '@/components/ui';
import type { AddTaskModalRef } from '../components/AddTaskModal';
import { AddTaskModal } from '../components/AddTaskModal';
import type { DragSharedValues } from '../components/DraggableTaskCard';
import { KanbanColumn } from '../components/KanbanColumn';
import { TaskCard } from '../components/TaskCard';
import type { KanbanColumns, KanbanStatus, KanbanTask, Priority } from '../constants';
import {
KANBAN_COLUMNS,
MOCK_TASKS,
PRIORITY_CONFIG,
PRIORITY_LEVELS,
PRODUCTIVITY_COLORS,
} from '../constants';
import { buildKanbanColumns, moveKanbanTask } from '../kanban-utils';
type LayoutMap = Record<KanbanStatus, LayoutRectangle | null>;
type ScrollOffsetMap = Record<KanbanStatus, number>;
type DragCardSize = { width: number; height: number };
type FilterPriority = Priority | 'all';
const COLUMN_WIDTH = 288;
const COLUMN_GAP = 16;
export default function KanbanBoard(): React.ReactElement {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
const colors = isDark ? PRODUCTIVITY_COLORS.dark : PRODUCTIVITY_COLORS.light;
const [columns, setColumns] = useState<KanbanColumns>(() =>
buildKanbanColumns(MOCK_TASKS),
);
const [columnTitles, setColumnTitles] = useState<Record<KanbanStatus, string>>({
todo: 'To Do',
doing: 'In Progress',
done: 'Done',
});
const [activeTask, setActiveTask] = useState<KanbanTask | null>(null);
const [dragCardSize, setDragCardSize] = useState<DragCardSize | null>(null);
const [priorityFilter, setPriorityFilter] = useState<FilterPriority>('all');
const addTaskModalRef = useRef<AddTaskModalRef>(null);
const columnLayouts = useRef<LayoutMap>({ todo: null, doing: null, done: null });
const listLayouts = useRef<LayoutMap>({ todo: null, doing: null, done: null });
const scrollOffsets = useRef<ScrollOffsetMap>({ todo: 0, doing: 0, done: 0 });
const cardLayouts = useRef<Record<string, LayoutRectangle>>({});
const dragX = useSharedValue(0);
const dragY = useSharedValue(0);
const dragOffsetX = useSharedValue(0);
const dragOffsetY = useSharedValue(0);
const dragOpacity = useSharedValue(0);
const dragScale = useSharedValue(1);
const dragRotation = useSharedValue(0);
const dragValues: DragSharedValues = useMemo(
() => ({ dragX, dragY, dragOffsetX, dragOffsetY }),
[dragOffsetX, dragOffsetY, dragX, dragY],
);
const isDragging = activeTask !== null;
// Filter tasks by priority
const filteredColumns = useMemo<KanbanColumns>(() => {
if (priorityFilter === 'all') return columns;
return {
todo: columns.todo.filter((t) => t.priority === priorityFilter),
doing: columns.doing.filter((t) => t.priority === priorityFilter),
done: columns.done.filter((t) => t.priority === priorityFilter),
};
}, [columns, priorityFilter]);
// Total task count for display
const totalTasks = useMemo(() => {
return columns.todo.length + columns.doing.length + columns.done.length;
}, [columns]);
const handleColumnLayout = useCallback(
(status: KanbanStatus, layout: LayoutRectangle) => {
columnLayouts.current[status] = layout;
},
[],
);
const handleListLayout = useCallback(
(status: KanbanStatus, layout: LayoutRectangle) => {
listLayouts.current[status] = layout;
},
[],
);
const handleTaskLayout = useCallback(
(taskId: string, layout: LayoutRectangle) => {
cardLayouts.current[taskId] = layout;
},
[],
);
const handleScrollOffset = useCallback(
(status: KanbanStatus, offsetY: number) => {
scrollOffsets.current[status] = offsetY;
},
[],
);
const getDropStatus = useCallback(
(absoluteX: number, absoluteY: number): KanbanStatus | null => {
const match = KANBAN_COLUMNS.find(({ status }) => {
const layout = columnLayouts.current[status];
if (!layout) return false;
return (
absoluteX >= layout.x &&
absoluteX <= layout.x + layout.width &&
absoluteY >= layout.y &&
absoluteY <= layout.y + layout.height
);
});
return match?.status ?? null;
},
[],
);
const getDropIndex = useCallback(
(status: KanbanStatus, taskId: string, absoluteY: number): number => {
const listLayout = listLayouts.current[status];
if (!listLayout) return columns[status].length;
const scrollOffset = scrollOffsets.current[status] ?? 0;
const cardTopY = absoluteY - dragOffsetY.value;
const relativeY = cardTopY - listLayout.y + scrollOffset;
const relativeCenterY = relativeY + (dragCardSize?.height ?? 0) / 2;
const targetTasks = columns[status].filter((task) => task.id !== taskId);
if (targetTasks.length === 0) return 0;
const targetIndex = targetTasks.findIndex((task) => {
const layout = cardLayouts.current[task.id];
if (!layout) return false;
const midpoint = layout.y + layout.height / 2;
return relativeCenterY < midpoint;
});
return targetIndex === -1 ? targetTasks.length : targetIndex;
},
[columns, dragCardSize?.height, dragOffsetY],
);
const handleDragStart = useCallback(
(task: KanbanTask, absoluteX: number, absoluteY: number) => {
const listLayout = listLayouts.current[task.status];
const cardLayout = cardLayouts.current[task.id];
if (!listLayout || !cardLayout) return;
// Haptic feedback on drag start
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const scrollOffset = scrollOffsets.current[task.status] ?? 0;
const cardAbsoluteX = listLayout.x + cardLayout.x;
const cardAbsoluteY = listLayout.y + cardLayout.y - scrollOffset;
dragOffsetX.value = absoluteX - cardAbsoluteX;
dragOffsetY.value = absoluteY - cardAbsoluteY;
dragX.value = cardAbsoluteX;
dragY.value = cardAbsoluteY;
dragOpacity.value = 1;
dragScale.value = withSpring(1.05, { damping: 15, stiffness: 300 });
dragRotation.value = withSpring(2, { damping: 20, stiffness: 200 });
setDragCardSize({ width: cardLayout.width, height: cardLayout.height });
setActiveTask(task);
},
[dragOffsetX, dragOffsetY, dragOpacity, dragScale, dragX, dragY, dragRotation],
);
const handleDragEnd = useCallback(
(
task: KanbanTask,
absoluteX: number,
absoluteY: number,
success: boolean,
) => {
if (success) {
const dropStatus = getDropStatus(absoluteX, absoluteY);
if (dropStatus) {
// Haptic feedback on successful drop
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
const dropIndex = getDropIndex(dropStatus, task.id, absoluteY);
setColumns((prev) => moveKanbanTask(prev, task.id, dropStatus, dropIndex));
}
}
dragOpacity.value = 0;
dragScale.value = withSpring(1, { damping: 18, stiffness: 220 });
dragRotation.value = withSpring(0, { damping: 20, stiffness: 200 });
setActiveTask(null);
setDragCardSize(null);
},
[getDropIndex, getDropStatus, dragOpacity, dragScale, dragRotation],
);
const handleAddTask = useCallback((status?: KanbanStatus) => {
addTaskModalRef.current?.open(status);
}, []);
const handleAddTaskSubmit = useCallback(
(task: Omit<KanbanTask, 'id'>) => {
const newTask: KanbanTask = {
...task,
id: `t${Date.now()}`,
};
setColumns((prev) => ({
...prev,
[task.status]: [...prev[task.status], newTask],
}));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
[],
);
const handleTitleChange = useCallback((status: KanbanStatus, newTitle: string) => {
setColumnTitles((prev) => ({ ...prev, [status]: newTitle }));
}, []);
const dragOverlayStyle = useAnimatedStyle<ViewStyle>(() => {
return {
opacity: dragOpacity.value,
transform: [
{ translateX: dragX.value },
{ translateY: dragY.value },
{ scale: dragScale.value },
{ rotate: `${dragRotation.value}deg` },
] as ViewStyle['transform'],
};
});
const renderFilterButton = (priority: FilterPriority) => {
const isActive = priorityFilter === priority;
const config = priority !== 'all' ? PRIORITY_CONFIG[priority] : null;
const bg = isActive
? priority === 'all'
? colors.accent
: isDark
? config!.bgDark
: config!.bgLight
: 'transparent';
const textColor = isActive
? priority === 'all'
? '#fff'
: isDark
? config!.textDark
: config!.textLight
: colors.textMuted;
return (
<Pressable
key={priority}
onPress={() => setPriorityFilter(priority)}
className="px-3 py-1.5 rounded-full flex-row items-center gap-1"
style={{
backgroundColor: bg,
borderWidth: 1,
borderColor: isActive ? textColor : colors.columnBorder,
}}
>
{priority === 'high' && (
<Ionicons name="arrow-up" size={12} color={textColor} />
)}
{priority === 'low' && (
<Ionicons name="arrow-down" size={12} color={textColor} />
)}
<Text className="text-xs font-semibold" style={{ color: textColor }}>
{priority === 'all' ? 'All' : config!.label}
</Text>
</Pressable>
);
};
return (
<View className="flex-1" style={{ backgroundColor: colors.background }}>
<FocusAwareStatusBar />
<Stack.Screen options={{ headerShown: false }} />
{/* Header */}
<View
className="px-6 pt-14 pb-4 border-b"
style={{ borderBottomColor: colors.columnBorder }}
>
<View className="flex-row justify-between items-center mb-4">
<View>
<Text
className="text-2xl font-black tracking-tight"
style={{ color: colors.text }}
>
Task Flow
</Text>
<Text className="font-medium" style={{ color: colors.textMuted }}>
{totalTasks} tasks • Mobile App Redesign
</Text>
</View>
<View className="flex-row -space-x-3">
{[1, 2, 3].map((i) => (
<View
key={i}
className="w-10 h-10 rounded-full items-center justify-center"
style={{
borderWidth: 2,
borderColor: colors.background,
backgroundColor: isDark ? '#374151' : '#e5e7eb',
}}
>
<Ionicons name="person" size={16} color={colors.textMuted} />
</View>
))}
<View
className="w-10 h-10 rounded-full items-center justify-center"
style={{
borderWidth: 2,
borderColor: colors.background,
backgroundColor: colors.accent,
}}
>
<Text className="font-bold text-xs text-white">+2</Text>
</View>
</View>
</View>
{/* Filter Bar */}
<View className="flex-row items-center gap-2">
<Ionicons name="filter-outline" size={16} color={colors.textMuted} />
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }}
>
{renderFilterButton('all')}
{Object.values(PRIORITY_LEVELS).map((p) => renderFilterButton(p))}
</ScrollView>
</View>
</View>
{/* Board */}
<ScrollView
horizontal
pagingEnabled
snapToInterval={COLUMN_WIDTH + COLUMN_GAP}
decelerationRate="fast"
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 100 }}
className="flex-1"
scrollEnabled={!isDragging}
>
{KANBAN_COLUMNS.map((column) => (
<KanbanColumn
key={column.status}
title={columnTitles[column.status]}
status={column.status}
icon={column.icon}
tasks={filteredColumns[column.status]}
activeTaskId={activeTask?.id ?? null}
dragValues={dragValues}
isDragging={isDragging}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onColumnLayout={handleColumnLayout}
onListLayout={handleListLayout}
onTaskLayout={handleTaskLayout}
onScrollOffset={handleScrollOffset}
onAddTask={() => handleAddTask(column.status)}
onTitleChange={handleTitleChange}
/>
))}
</ScrollView>
{/* Drag Overlay */}
{activeTask ? (
<Animated.View
pointerEvents="none"
style={[
dragOverlayStyle,
{
position: 'absolute',
left: 0,
top: 0,
zIndex: 50,
elevation: 12,
width: dragCardSize?.width,
height: dragCardSize?.height,
},
]}
>
<TaskCard task={activeTask} style={{ marginBottom: 0 }} isDragging />
</Animated.View>
) : null}
{/* FAB */}
<Pressable
onPress={() => handleAddTask()}
className="absolute bottom-8 right-6 w-14 h-14 rounded-full items-center justify-center"
style={{
backgroundColor: colors.accent,
shadowColor: colors.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
}}
accessibilityRole="button"
accessibilityLabel="Add task"
>
<Ionicons name="add" size={32} color="white" />
</Pressable>
{/* Add Task Modal */}
<AddTaskModal ref={addTaskModalRef} onAddTask={handleAddTaskSubmit} />
</View>
);
}
Building a full app?
VibeFast Pro is a complete Expo + Next.js starter kit with authentication, payments (RevenueCat), AI features, backend (Convex/Supabase), and more. Ship your app in days, not months.