Getting started
Usage
The board is controlled: it renders the columns you pass and reports drops. You move the item in state.
Data model
Columns are plain objects. Items only need an id.
Define an item type with an id: string and a header type of your choosing. TypeScript will infer both through the component's generics.
type Lead = {
id: string;
name: string;
company: string;
priority: 'low' | 'medium' | 'high';
};
type ColumnHeader = {
title: string;
subtitle?: string;
};
type Column = {
header: ColumnHeader;
items: Lead[];
};const initialColumns: Column[] = [
{
header: { title: 'Enquiry', subtitle: 'Target 20 leads' },
items: [
{
id: '101',
name: 'Bhageerathan Pillai',
company: 'Muthalali Inc.',
priority: 'high',
},
{
id: '102',
name: 'Kittunni',
company: 'Army Inc.',
priority: 'medium',
},
],
},
{
header: { title: 'Interested', subtitle: 'Make sure fixed' },
items: [
{
id: '104',
name: 'Shaji Pappan',
company: 'Kalasamithi Inc.',
priority: 'high',
},
],
},
{
header: { title: 'Sold', subtitle: 'Check on reports' },
items: [
{
id: '106',
name: 'Mahesh Bhavana',
company: 'Bhavana Studio Inc.',
priority: 'low',
},
],
},
];The id is the contract
item.id. Keep ids stable and unique across all columns so the drag preview and drop callback can resolve the right item.Basic example
A complete screen with headers, cards, and a drop handler.
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import ReactNativeKanbanBoard from 'react-native-expo-kanban';
export default function BoardScreen() {
const { width } = useWindowDimensions();
const [columns, setColumns] = useState(initialColumns);
const renderHeader = (header: ColumnHeader) => (
<View style={styles.header}>
<Text style={styles.headerTitle}>{header.title}</Text>
{header.subtitle ? (
<Text style={styles.headerSubtitle}>{header.subtitle}</Text>
) : null}
</View>
);
const renderItem = (item: Lead, isDragged?: boolean) => (
<View style={[styles.card, isDragged && styles.cardDragged]}>
<Text style={styles.cardTitle}>{item.name}</Text>
<Text style={styles.cardMeta}>{item.company}</Text>
<Text style={styles.cardMeta}>Priority: {item.priority}</Text>
</View>
);
const onDragEnd = ({ fromColumnIndex, toColumnIndex, itemId }: {
fromColumnIndex: number;
toColumnIndex: number;
itemId: string;
}) => {
if (fromColumnIndex === toColumnIndex) return;
setColumns((current) => {
const next = current.map((column) => ({
...column,
items: [...column.items],
}));
const source = next[fromColumnIndex].items;
const index = source.findIndex((item) => item.id === itemId);
if (index === -1) return current;
const [moved] = source.splice(index, 1);
next[toColumnIndex].items.push(moved);
return next;
});
};
return (
<ReactNativeKanbanBoard
columnData={columns}
renderHeader={renderHeader}
renderItem={renderItem}
onDragEnd={onDragEnd}
columnWidth={width * 0.8}
gapBetweenColumns={12}
columnContainerStyle={styles.column}
columnContainerStyleOnDrag={styles.columnHover}
columnHeaderStyle={styles.columnHeader}
/>
);
}
const styles = StyleSheet.create({
column: {
borderRadius: 16,
backgroundColor: '#f4f4f5',
},
columnHover: {
backgroundColor: '#e4e4e7',
borderColor: '#a1a1aa',
borderWidth: 1,
},
columnHeader: {
paddingBottom: 8,
},
header: {
gap: 2,
},
headerTitle: {
fontSize: 16,
fontWeight: '600',
},
headerSubtitle: {
fontSize: 12,
color: '#71717a',
},
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 14,
gap: 4,
borderWidth: 1,
borderColor: '#e4e4e7',
},
cardDragged: {
transform: [{ scale: 1.02 }],
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
cardTitle: {
fontSize: 14,
fontWeight: '600',
},
cardMeta: {
fontSize: 12,
color: '#71717a',
},
});Rendering cards
renderItem and renderHeader are called for every card and column.
renderItem receives the item and an optional isDragged flag. The flag is true for the floating preview rendered while dragging, which lets you style the lifted card differently.
const renderItem = (item: Lead, isDragged?: boolean) => (
<View style={[styles.card, isDragged && styles.cardDragged]}>
<Text style={styles.cardTitle}>{item.name}</Text>
<Text style={styles.cardMeta}>{item.company}</Text>
</View>
);
const renderHeader = (header: ColumnHeader) => (
<View>
<Text style={styles.headerTitle}>{header.title}</Text>
<Text style={styles.headerSubtitle}>{header.subtitle}</Text>
</View>
);The in-column card is wrapped in a Pressable with a 200ms long-press delay. The card itself fades out while its drag preview follows your finger.
Handling drops
onDragEnd only fires when the item lands in a different column.
const onDragEnd = ({
fromColumnIndex,
toColumnIndex,
itemId,
}: {
fromColumnIndex: number;
toColumnIndex: number;
itemId: string;
}) => {
setColumns((current) => {
const next = current.map((column) => ({
...column,
items: [...column.items],
}));
const source = next[fromColumnIndex].items;
const index = source.findIndex((item) => item.id === itemId);
if (index === -1) return current;
const [moved] = source.splice(index, 1);
next[toColumnIndex].items.push(moved);
return next;
});
};Always return new arrays and objects so React can re-render the board. The snippet clones each column and its items array before splicing the moved item into the destination.
Drops inside the same column are ignored
onDragEnd is not called and the preview springs back into place.Styling
Style columns, headers, and the drag-hover state independently.
<ReactNativeKanbanBoard
columnData={columns}
renderItem={renderItem}
renderHeader={renderHeader}
onDragEnd={onDragEnd}
// The board fills the screen; style the columns instead
containerStyle={{ flex: 1 }}
contentContainerStyle={{ paddingVertical: 12 }}
columnContainerStyle={{
backgroundColor: '#f4f4f5',
borderRadius: 16,
}}
columnHeaderStyle={{ paddingBottom: 8 }}
columnContainerStyleOnDrag={{
backgroundColor: '#e4e4e7',
borderColor: '#a1a1aa',
borderWidth: 1,
}}
gapBetweenColumns={12}
columnWidth={320}
/>columnContainerStyle is applied to every column. columnContainerStyleOnDrag is merged on top when the column is the current drop target. Internal column padding comes from gapBetweenColumns.
Layout & sizing
How width, gap, and initial pagination behave.
columnWidthdefaults to 80% ofDimensions.get('screen').width.gapBetweenColumnsdefaults to 12 and is also used as column padding.- The board centers the initial column 200ms after mount by calling
paginate('center'). - The horizontal list snaps to
columnWidthwith paging enabled.
Resizing columns
columnWidth re-runs centering, but avoid changing it on every scroll frame. Derive it from useWindowDimensions once per render instead.Performance notes
Where the component already optimizes, and where you can help.
DataCardis wrapped inReact.memoto avoid re-rendering every card on drag state changes.- The first column renders 8 items; other columns render 3 initially.
- Only the focused column is draggable, which also keeps gestures from fighting the horizontal pager.
- Keep
renderItemandrenderHeaderstable withuseCallbackif they close over changing values.