About this animation
A drag-to-reorder task board with live insertion gaps and configurable columns, fonts, column width ratio, and lift tilt.
Why this one
A task feels lifted out of the board, rather than copied onto a rigid drag layer. Neighboring tasks make room with springs, the viewport follows the finger across columns, and a green eight-stroke celebration belongs specifically to completion. The Nimbus demo adds editorial sprint typography, realistic tasks, overlapping team avatars, and a light/dark theme switch.
How it works
- Measure the container with
onLayout; each column takes 78% of the board width. Horizontal scrolling reveals the next column. Vertical scrolling keeps larger task lists reachable. - A 280 ms long press lifts a task to 1.02× scale with a subtle tilt (up to 3° by default). A manually activated pan shares recognition with the hold; a competing Gesture Handler tap opens the move menu.
- Keep task order, drag coordinates, insertion index, viewport offset, and interaction phase on the UI thread. The phases are
rest → lifted → dragging → dropping → rest. - Derive neighboring task positions from the shared order and insertion slot. Keep a dashed placeholder at the original position until release. A frame callback scrolls at up to 550 points/second within 40 points of either edge.
- Settle the task with a
{ damping: 33, stiffness: 260, mass: 1, overshootClamping: true }spring and inherited horizontal velocity. Commit the board to React and callonChangeafter the spring settles. The card settles without oscillation. Counters roll with fading outgoing digits. - A completed column draws the kit's Lucide check with a Skia path trim and emits eight fine particles. Only one Skia canvas is used; static kit icons are SVG images.
Install
Copy this folder without demo/ into your project, then install SDK-compatible packages:
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler expo-image @shopify/react-native-skiaFollow your Expo SDK's Reanimated setup. On web, initialize Skia/CanvasKit before importing this component (for example, a deferred WithSkiaWeb entry). The host must provide loaded font families if custom fonts are passed. Icons: Lucide (ISC); the copied paths are in icons.ts.
Usage
import { KanbanDragCards, type KanbanColumn } from './kanban-drag-cards';
const columns: KanbanColumn[] = [
{
id: 'planned', title: 'Planned', color: '#7C5CFF',
cards: [{ id: 'task', title: 'Polish the welcome flow', tag: 'Design',
color: '#7C5CFF', assignee: 'AM', date: 'Oct 14' }],
},
{ id: 'done', title: 'Done', color: '#10B981', completed: true, cards: [] },
];
export function Board() {
return <KanbanDragCards columns={columns} />;
}const boardRef = useRef<KanbanDragCardsRef>(null);
<KanbanDragCards
ref={boardRef}
columns={columns}
colorScheme="auto"
columnWidthRatio={0.8}
holdDuration={320}
maxTilt={3}
springDamping={33}
onChange={(nextColumns, movedCardId) => saveBoard(nextColumns, movedCardId)}
/>;
boardRef.current?.move('task', 'done', 0);The parent must give the board a bounded height. The component owns its order; remount with a new key when replacing the initial dataset. All card and column IDs must be unique. Color values use six-digit hex strings because translucent tints derive from them.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | readonly KanbanColumn[] | Required | Initial columns, cards, and completion flags |
fontFamily | string | System | Body and menu font |
headingFontFamily | string | fontFamily | Task and column headings |
monoFontFamily | string | System | Counts and due dates |
colorScheme | 'light' | 'dark' | 'auto' | 'light' | Surface and text theme |
columnWidthRatio | number | 0.78 | Column width, clamped to 0.65–0.86 |
holdDuration | number | 280 | Lift delay in milliseconds |
maxTilt | number | 3 | Horizontal-velocity tilt limit in degrees |
springDamping | number | 33 | Drop spring damping; stiffness is 260 and mass is 1. Values below the damping needed for ζ ≥ 0.85 are raised to that minimum; overshoot is clamped. |
onChange | (columns, movedCardId) => void | — | Complete ordered board after settlement |
accessibilityLabel | string | 'Task board' | Board label |
style | StyleProp<ViewStyle> | — | Root container style |
testID | string | — | Testing identifier |
KanbanCard includes id, title, tag, color, assignee, date, optional comments, attachments, and optional [completed, total] subtasks. KanbanColumn includes id, title, color, cards, and optional completed.
Ref methods: move(cardId, columnId, index = 0), focus(columnId), and reset(). reset() restores the initial board and cancels an active scripted move; it does not call onChange.
Demo timeline
The autoplay loop lasts 5.2 seconds. At 100 ms it restores the sprint and focuses In progress; at 380 ms it lifts “Polish onboarding empty states,” traverses the right edge into Review, and drops it. At 1,750 ms it lifts “Tighten the team invite flow” into Done. Its check and green burst start as the drop settles, within the first three seconds. The demo owns and cleans up its timers; the component has no autoplay prop.
Compatibility
Built for Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, expo-image 58, and Skia 2.14.0. Uses the installed Expo Go modules; no additional native dependency is introduced. Supports iOS, Android, and web.
Accessibility
Every task exposes a button role, a descriptive label, and an activation action for its Move to menu. Tapping is handled by Gesture Handler, so the alternative also works after native gesture recognition. Menu rows and the close control provide at least 44-point targets. With reduced motion enabled, task moves use a 180 ms fade; tilt, lift scaling, edge auto-scroll, and particles are disabled. Tap-to-move still navigates to the destination immediately. The menu is marked as modal for accessibility.
All springs set mass: 1 explicitly and clamp overshoot. Lift, tilt, press feedback, and neighboring task reflow use damping 35 and stiffness 300 (ζ ≈ 1.01). Drop springs use damping 33 and stiffness 260 by default (ζ ≈ 1.02), with a damping floor of 1.7 * Math.sqrt(260) (ζ ≥ 0.85), including custom springDamping values. Horizontal drops inherit release velocity and stop at the destination without bouncing past it.




