Expo Kanban
On this page7 sections

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.

types.ts
type Lead = {
  id: string;
  name: string;
  company: string;
  priority: 'low' | 'medium' | 'high';
};

type ColumnHeader = {
  title: string;
  subtitle?: string;
};

type Column = {
  header: ColumnHeader;
  items: Lead[];
};
columns.ts
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

Card identity comes from 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.

BoardScreen.tsx
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.

renderers.tsx
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.

onDragEnd.ts
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

The component compares the source and destination column indices. If they match, onDragEnd is not called and the preview springs back into place.

Styling

Style columns, headers, and the drag-hover state independently.

StyledBoard.tsx
<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.

  • columnWidth defaults to 80% of Dimensions.get('screen').width.
  • gapBetweenColumns defaults 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 columnWidth with paging enabled.

Resizing columns

Changing 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.

  • DataCard is wrapped in React.memo to 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 renderItem and renderHeader stable with useCallback if they close over changing values.