Free
Open source • Copy & paste

Task 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
Browse more kits

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.

Learn more