Build a Kanban Board With Drag-and-Drop in React with Shadcn

Build a Kanban Board With Drag-and-Drop in React with Shadcn

Building interfaces that feel responsive and natural is satisfying. There’s something great about dragging a card across the screen and watching it snap into place.

In this tutorial, we’ll build a Trello-like board application based on this user story:

As Peter, I want to track the progress of my to do list

We’ll use Shadcn Admin Kit for the user interface, shadcn/ui for UI components, and @hello-pangea/dnd for drag-and-drop interactions.

By the end, we’ll have built an application where we can:

  • View todos in three columns (To Do, In Progress, Done)
  • Drag and drop cards between columns
  • Create and edit todos through modal dialogs
  • See instant UI updates without waiting for the server

Let’s dive in and build something great!

Bootstrap the Project: Vite, Tailwind, and Shadcn Admin Kit

Shadcn Admin Kit combines shadcn/ui components with the React Admin framework. It provides a solid foundation for building admin interfaces.

In this section, we’ll set up the project, configure the necessary tools, and prepare the groundwork for our Kanban board.

Create a New React Project With Vite and TypeScript

Vite provides fast development and optimized builds. Create a new project:

Terminal window
pnpm create vite@latest my-kanban-app -- --template react-ts

Install Tailwind CSS for Component Styling

Shadcn/ui components use Tailwind CSS. Install it:

Terminal window
pnpm add tailwindcss @tailwindcss/vite

Replace everything in src/index.css with the following:

@import "tailwindcss";

Configure Path Aliases to Avoid Relative Path Hell

Path aliases let us write @/components instead of ../../../components.

First, update the root tsconfig.json:

tsconfig.json
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

Next, apply the same configuration to tsconfig.app.json so the IDE can resolve these paths correctly:

tsconfig.app.json
{
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

Since we’ll be using Node.js APIs (like path), we need to install the Node.js type definitions:

Terminal window
pnpm add -D @types/node

Now we need to configure Vite to resolve our path aliases at build time. Update vite.config.ts to include both Tailwind and the path resolution:

vite.config.ts
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
});

Installing Shadcn UI and Shadcn Admin Kit

With our project structure ready, it’s time to initialize shadcn/ui. Run the initialization command:

Terminal window
pnpm dlx shadcn@latest init

Follow the prompts to configure the preferences. This creates a components.json file with project settings.

Now add the Shadcn Admin Kit components:

Terminal window
pnpm dlx shadcn@latest add https://marmelab.com/shadcn-admin-kit/r/admin.json

There’s a compatibility issue with the latest TypeScript version. We need to disable the verbatimModuleSyntax option in tsconfig.app.json:

tsconfig.app.json
{
"compilerOptions": {
// ...
"verbatimModuleSyntax": true,
"verbatimModuleSyntax": false
}
}

The main entry point of the new application is main.tsx, which renders the App component into the DOM.

src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

The <App> component currently shows the default Vite welcome screen. Let’s replace it with our Shadcn Admin Kit application. Update src/App.tsx:

src/App.tsx
import { Admin } from '@/components/admin';
function App() {
return <Admin></Admin>;
}
export default App;

Let’s verify everything is working correctly. Start the development server:

Terminal window
pnpm run dev

We should see the Shadcn Admin Kit interface! However, it’s empty because we haven’t configured any resources yet.

Configure the Mock Data Provider With Sample Todos

Before building the UI, we need a data layer. Let’s use ra-data-fakerest, a mock data provider that simulates a REST API in memory. This way, we can focus on features without setting up a backend.

Install the data provider:

Terminal window
pnpm add ra-data-fakerest

Now let’s define our data model. We need a Todo type and status column definitions for our Kanban board.

Create the types file:

src/admin/todos/todoTypes.ts
export type Todo = {
id: number;
title: string;
description: string;
status: "todo" | "inProgress" | "done";
order: number;
};
export type StatusColumn = {
id: string;
label: string;
color: string;
};

Next, define the status columns that will represent the three states in our Kanban board:

src/admin/todos/todoConsts.ts
import type { StatusColumn } from "./todoTypes";
export const statusColumns: StatusColumn[] = [
{ id: "todo", label: "To Do", color: "bg-slate-100 dark:bg-slate-800" },
{
id: "inProgress",
label: "In Progress",
color: "bg-blue-100 dark:bg-blue-900",
},
{ id: "done", label: "Done", color: "bg-green-100 dark:bg-green-900" },
];

For now, let’s create a placeholder TodoList component. We’ll implement the actual board layout in the next section:

src/admin/todos/TodoList.tsx
import { List, type ListProps } from "@/components/admin";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd";
import { RecordContextProvider, useListContext } from "ra-core";
import { TodoCard } from "./TodoCard";
import { statusColumns } from "./todoConsts";
import { TodoCreate } from "./TodoCreate";
import type { Todo } from "./todoTypes";
import { useTodoKanban } from "./useTodoKanban";
export const TodoList = () => {
return null;
};

Create an index file to export our todo resource components and configuration:

src/admin/todos/index.ts
export { TodoList as list } from "./TodoList";
export const recordRepresentation = "title";

Now set up the data provider with some initial sample todos:

src/lib/dataProvider.ts
import fakeRestProvider from 'ra-data-fakerest';
export const dataProvider = fakeRestProvider(
{
todos: [
{
id: 1,
title: 'Finish presentation',
description: 'Prepare slides for project demo',
status: 'todo',
order: 0
},
{
id: 2,
title: 'Review code',
description: 'Do code review for PR #123',
status: 'inProgress',
order: 0
},
{
id: 3,
title: 'Write tests',
description: 'Add unit tests for new component',
status: 'todo',
order: 1
}
]
},
true
);

Finally, wire everything together by updating App.tsx to use our data provider and register the todos resource:

src/App.tsx
import { Resource } from "ra-core";
import { Admin } from "@/components/admin";
import { dataProvider } from "@/lib/dataProvider";
import * as todos from "@/admin/todos";
function App() {
return (
<Admin dataProvider={dataProvider}>
<Resource name="todos" {...todos} />
</Admin>
);
}
export default App;

Build the Three-Column Kanban Board Layout

With the data layer ready, let’s build the visual structure. We’ll create three columns representing different statuses: “To Do”, “In Progress”, and “Done”.

Our approach:

  1. Create a custom hook to organize todos by status
  2. Build a card component to display individual todos
  3. Assemble the complete board with columns and cards

Group Todos by Status With a Custom Hook

We need a way to group todos by status. Create a custom hook called useTodosByStatus that takes the flat list from React Admin and organizes it into columns.

The hook:

  • Accesses todo data from React Admin’s list context
  • Groups todos by status
  • Sorts todos by their order property
  • Returns a memoized object with organized todos
src/admin/todos/useTodosByStatus.ts
import { useListContext } from "ra-core";
import { useMemo } from "react";
import type { StatusColumn, Todo } from "./todoTypes";
export const useTodosByStatus = ({
statusColumns,
}: UseTodosByStatusParams): UseTodosByStatusResult => {
const { data } = useListContext<Todo>();
const todosByStatus = useMemo(() => {
return statusColumns.reduce((acc, column) => {
const columnTodos =
data?.filter((todo) => todo.status === column.id) || [];
acc[column.id] = columnTodos.sort((a, b) => a.order - b.order);
return acc;
}, {} as Record<string, Todo[]>);
}, [data, statusColumns]);
return useMemo(
() => ({ todosByStatus }),
[todosByStatus]
);
};
type UseTodosByStatusParams = {
statusColumns: StatusColumn[];
}
type UseTodosByStatusResult = {
todosByStatus: Record<string, Todo[]>;
}

The useMemo hook ensures we only recalculate the grouped todos when the data or columns change, optimizing performance.

Display Individual Todos With a Card Component

We need a component to display each todo. The TodoCard shows the todo’s title and description.

We’ll use React Admin’s useRecordContext hook to access the todo data provided by RecordContextProvider. This is a common pattern in React Admin for passing record data to child components.

src/admin/todos/TodoCard.tsx
import { useRecordContext } from 'ra-core';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import type { Todo } from './todoTypes';
export const TodoCard = () => {
const todo = useRecordContext<Todo>();
if (!todo) return null;
return (
<>
<Card className="gap-2 p-4 transition-shadow">
<CardHeader className="p-0">
<h4 className="p-0 text-sm leading-tight font-semibold">{todo.title}</h4>
</CardHeader>
<CardContent className="p-0">
<p className="text-muted-foreground line-clamp-2 text-xs">{todo.description}</p>
</CardContent>
</Card>
</>
);
};

The card uses shadcn/ui’s Card components with Tailwind classes for styling. The line-clamp-2 utility ensures descriptions don’t overflow by limiting them to two lines.

Assemble the Board With Columns and Cards

Let’s put everything together in the TodoList component.

The structure:

  • A <List> component from React Admin for data fetching
  • A KanbanBoard component that renders three columns
  • Each column displays a header with status name and todo count
  • Cards rendered using RecordContextProvider to pass todo data

Update the TodoList.tsx file:

src/admin/todos/TodoList.tsx
import { RecordContextProvider } from "ra-core";
import { List } from "@/components/admin";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TodoCard } from "./TodoCardBack";
import { statusColumns } from "./todoConsts";
import { useTodosByStatus } from "./useTodosByStatusMemo";
export const TodoList = () => {
return (
<List
resource="todos"
className="h-full flex flex-col"
pagination={false}
perPage={10_000}
>
<KanbanBoard />
</List>
);
};
const KanbanBoard = () => {
const { todosByStatus } = useTodosByStatus({
statusColumns,
});
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 h-full pb-2">
{statusColumns.map((column) => (
<div key={column.id} className="flex flex-col h-full">
<div className={cn("rounded-t-lg p-3", column.color)}>
<h3 className="font-semibold text-lg flex items-center justify-between">
{column.label}
<Badge variant="secondary" className="ml-2">
{todosByStatus[column.id].length}
</Badge>
</h3>
</div>
<div
className={cn(
"flex-1 border border-t-0 rounded-b-lg p-2 transition-colors"
)}
>
<div className="space-y-2">
{todosByStatus[column.id].map((todo) => (
<RecordContextProvider value={todo} key={todo.id}>
<TodoCard />
</RecordContextProvider>
))}
</div>
</div>
</div>
))}
</div>
);
};

Let’s break down what’s happening here:

  1. List Component: We set pagination={false} and perPage={10_000} to load all todos at once, which is necessary for the Kanban view
  2. Column Headers: Each column has a colored header with the status label and a badge showing the count
  3. RecordContextProvider: Wraps each todo card to provide the todo data to child components

At this point, if we refresh the browser, we should see a beautiful three-column Kanban board with sample todos displayed in their respective columns! However, we can’t move them yet - that’s what we’ll implement in the next section.

Add Drag-and-Drop to Move Cards Between Columns

This is the most exciting part: making the board interactive with drag-and-drop. We’ll use @hello-pangea/dnd, a React library that provides accessible and smooth drag interactions.

Terminal window
pnpm add @hello-pangea/dnd

Implement the Move Todo Function With Order Recalculation

Enhance the useTodosByStatus hook to handle moving todos. This involves:

  1. Updating the todo’s status when moved to a different column
  2. Recalculating the order for all affected todos
  3. Persisting changes via the data provider

Update useTodosByStatus.ts to add the moveTodo function:

src/admin/todos/useTodosByStatus.ts
import { useState, useEffect, useCallback } from "react";
import { useListContext, useDataProvider } from "ra-core";
import type { Todo } from "./todoTypes";
export const useTodosByStatus = ({
statusColumns,
}: UseTodosByStatusProps): UseTodosByStatusReturn => {
const dataProvider = useDataProvider();
const { data, refetch } = useListContext<Todo>();
// ...
const moveTodo = useCallback(
async ({
todoId,
sourceColumnId,
destinationColumnId,
destinationIndex,
newStatus,
previousData,
}: {
todoId: number;
sourceColumnId: string;
destinationColumnId: string;
destinationIndex: number;
newStatus: Todo["status"];
previousData: Todo;
}) => {
const newState = { ...todosByStatus };
const sourceTodos = todosByStatus[sourceColumnId].filter(
(todo) => todo.id !== todoId
);
if (sourceColumnId === destinationColumnId) {
const updatedTodo = { ...previousData };
sourceTodos.splice(destinationIndex, 0, updatedTodo);
newState[sourceColumnId] = sourceTodos;
} else {
newState[sourceColumnId] = sourceTodos;
const updatedTodo = { ...previousData, status: newStatus };
const destTodos = [...todosByStatus[destinationColumnId]];
destTodos.splice(destinationIndex, 0, updatedTodo);
newState[destinationColumnId] = destTodos;
}
const updates: Array<{
id: number;
status: Todo["status"];
order: number;
}> = [];
if (sourceColumnId === destinationColumnId) {
newState[sourceColumnId].forEach((todo, index) => {
updates.push({ id: todo.id, status: todo.status, order: index });
});
} else {
newState[sourceColumnId].forEach((todo, index) => {
updates.push({ id: todo.id, status: todo.status, order: index });
});
newState[destinationColumnId].forEach((todo, index) => {
updates.push({ id: todo.id, status: todo.status, order: index });
});
}
try {
await Promise.all(
updates.map((update) =>
dataProvider.update("todos", {
id: update.id,
data: { status: update.status, order: update.order },
previousData: data?.find((t) => t.id === update.id),
})
)
);
refetch();
} catch (error) {
console.error("Error updating todos:", error);
refetch();
}
},
[todosByStatus, dataProvider, refetch, data]
);
return useMemo(
() => ({ todosByStatus, moveTodo }),
[todosByStatus, moveTodo]
);
};
interface UseTodosByStatusReturn {
// ...
moveTodo: (params: {
todoId: number;
sourceColumnId: string;
destinationColumnId: string;
destinationIndex: number;
newStatus: Todo["status"];
previousData: Todo;
}) => Promise<void>;
}

This moveTodo function is doing a lot of heavy lifting:

  • It removes the todo from its source column
  • Adds it to the destination column at the correct position
  • Calculates new order values for all affected todos
  • Batch updates all changes to the backend in parallel

Bridge Drag Events With the Move Function

Create a dedicated hook that bridges @hello-pangea/dnd’s event system and our moveTodo function.

Create useTodoKanban.ts:

src/admin/todos/useTodoKanban.ts
import { DropResult } from "@hello-pangea/dnd";
import { useListContext } from "ra-core";
import { useCallback, useMemo } from "react";
import type { Todo, StatusColumn } from "./todoTypes";
import { useTodosByStatus } from "./useTodosByStatus";
export const useTodoKanban = ({
statusColumns,
}: UseTodoKanbanProps): UseTodoKanbanReturn => {
const { data } = useListContext<Todo>();
const { todosByStatus, moveTodo } = useTodosByStatus({
statusColumns,
});
const onDragStart = useCallback(() => {}, []);
const onDragEnd = useCallback(
async (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!destination) {
return;
}
if (
source.droppableId === destination.droppableId &&
source.index === destination.index
) {
return;
}
const todoId = parseInt(draggableId.replace("todo-", ""));
const newStatus = destination.droppableId as Todo["status"];
const previousData = data?.find((t) => t.id === todoId);
if (!previousData) return;
await moveTodo({
todoId,
sourceColumnId: source.droppableId,
destinationColumnId: destination.droppableId,
destinationIndex: destination.index,
newStatus,
previousData,
});
},
[data, moveTodo]
);
return useMemo(
() => ({
todosByStatus,
onDragStart,
onDragEnd,
}),
[todosByStatus, onDragStart, onDragEnd]
);
};
interface UseTodoKanbanProps {
statusColumns: StatusColumn[];
}
interface UseTodoKanbanReturn {
todosByStatus: Record<string, Todo[]>;
onDragStart: () => void;
onDragEnd: (result: DropResult) => Promise<void>;
}

The onDragEnd callback handles the core logic:

  • Early returns if the drop is invalid (no destination, or same position)
  • Extracts the todo ID from the draggable ID string
  • Calls our moveTodo function with all the necessary parameters

Wrap the Board in Drag-and-Drop Contexts

Update the TodoList component to use drag-and-drop. Wrap the board in DragDropContext and make each column a Droppable area with Draggable cards.

Update TodoList.tsx:

src/admin/todos/TodoList.tsx
// ...
import { useTodoKanban } from "./useTodoKanban";
// ...
const KanbanBoard = () => {
const { todosByStatus, onDragStart, onDragEnd } = useTodoKanban({
statusColumns,
});
return (
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
{statusColumns.map((column) => (
<div key={column.id} className="flex flex-col h-full">
<div className={cn("rounded-t-lg p-3", column.color)}>
<h3 className="font-semibold text-lg flex items-center justify-between">
{column.label}
<Badge variant="secondary" className="ml-2">
{todosByStatus[column.id].length}
</Badge>
</h3>
</div>
<Droppable droppableId={column.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
"flex-1 border border-t-0 rounded-b-lg p-2 transition-colors",
snapshot.isDraggingOver
? "bg-slate-50 dark:bg-slate-900"
: "bg-white dark:bg-slate-950"
)}
>
<div className="space-y-2">
{todosByStatus[column.id].map((todo, index) => (
<DraggableTodoCard
key={todo.id}
todo={todo}
index={index}
/>
))}
{provided.placeholder}
</div>
</div>
)}
</Droppable>
</div>
))}
</div>
</DragDropContext>
);
};
const DraggableTodoCard = ({ todo, index }: { todo: Todo; index: number }) => (
<Draggable draggableId={`todo-${todo.id}`} index={index}>
{(provided, snapshot) => (
<RecordContextProvider value={todo}>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={cn(snapshot.isDragging && "shadow-lg rotate-2")}>
<TodoCard />
</div>
</div>
</RecordContextProvider>
)}
</Draggable>
);

Let’s break down the key components:

  1. DragDropContext: Wraps the entire board and provides drag-and-drop context
  2. Droppable: Makes each column a valid drop target, with visual feedback during dragging
  3. Draggable: Makes each card draggable, with smooth animations and rotation effect
  4. provided.placeholder: Maintains space in the list during dragging to prevent layout shifts

If we test the board now, we can drag and drop cards! However, we might notice a flicker when dropping - the card briefly disappears before reappearing. This happens because we’re waiting for the backend to confirm the change before updating the UI.

Fix Flickers with Optimistic UI Updates

We might notice a flicker when dropping cards - they briefly disappear before reappearing. To fix this, let’s update the UI immediately rather than waiting for the backend response.

Let’s modify useTodosByStatus to use local state that updates immediately when a card is moved:

src/admin/todos/useTodosByStatus.ts
import { useState, useEffect, useCallback } from "react";
import { useListContext, useDataProvider } from "ra-core";
import type { Todo } from "./todoTypes";
export const useTodosByStatus = ({
statusColumns,
}: UseTodosByStatusProps): UseTodosByStatusReturn => {
const dataProvider = useDataProvider();
// ...
const [todosByStatus, setTodosByStatus] = useState(
statusColumns.reduce((acc, column) => {
acc[column.id] = [];
return acc;
}, {} as Record<string, Todo[]>)
);
const moveTodo = useCallback(
async (/* ... */) => {
const newState = { ...todosByStatus };
// ...
setTodosByStatus(newState);
// ...
try {
// ...
refetch();
} catch (error) {
console.error("Error updating todos:", error);
refetch();
}
},
[todosByStatus, dataProvider, refetch, data]
);
return useMemo(
() => ({ todosByStatus, moveTodo }),
[todosByStatus, moveTodo]
);
};

Now when you drag a card, the UI updates instantly! The setTodosByStatus(newState) call happens before the API calls, making the interaction feel immediate.

However, there’s one issue: when you refresh the page or the data updates from the backend, our local state doesn’t sync with the new data. We need to add an effect to keep them in sync.

Sync Local State With Backend Updates

Add a useEffect to sync local state with React Admin data:

src/admin/todos/useTodosByStatus.ts
import { useState, useEffect, useCallback } from "react";
import { useListContext, useDataProvider } from "ra-core";
import type { Todo } from "./todoTypes";
export const useTodosByStatus = ({
statusColumns,
}: UseTodosByStatusProps): UseTodosByStatusReturn => {
// ...
useEffect(() => {
const grouped = statusColumns.reduce((acc, column) => {
const columnTodos =
data?.filter((todo) => todo.status === column.id) || [];
acc[column.id] = columnTodos.sort((a, b) => a.order - b.order);
return acc;
}, {} as Record<string, Todo[]>);
setTodosByStatus(grouped);
}, [data, statusColumns]);
// ...
return useMemo(
() => ({ todosByStatus, moveTodo }),
[todosByStatus, moveTodo]
);
};

This useEffect ensures our local optimistic state stays synchronized with the source of truth from React Admin. Whenever the backend data changes (like after a page refresh or when another user makes changes), our local state updates accordingly.

The Kanban board now has smooth, flicker-free drag-and-drop that feels instant while remaining synchronized with the backend! The experience should feel polished and responsive when dragging cards between columns.

Next, let’s add the final piece: the ability to create and edit todos directly from the board.

Add Create and Edit Modals for Todo Management

The board has drag-and-drop, but users can’t create or edit todos yet. Let’s add modal dialogs.

We’ll build:

  1. A smart data provider that manages todo ordering
  2. A reusable form component for todo inputs
  3. A creation modal accessible from the board header
  4. An edit modal that opens when clicking any card

Calculate New Todo Order Automatically on Creation

Newly created todos should appear at the end of their column. Enhance the data provider to calculate the correct order value automatically.

Update dataProvider.ts to override the create method:

src/lib/dataProvider.ts
import fakeRestProvider from 'ra-data-fakerest';
import type { DataProvider } from 'ra-core';
import type { Todo } from '@/admin/todos/todoTypes';
export const dataProvider = fakeRestProvider({
const baseDataProvider = fakeRestProvider({
/* ... */
});
export const dataProvider: DataProvider = {
...baseDataProvider,
create: async (resource, params) => {
if (resource === 'todos') {
// Get all todos with the same status to find the max order
const { data: existingTodos } = await baseDataProvider.getList('todos', {
pagination: { page: 1, perPage: 10_000 },
sort: { field: 'order', order: 'DESC' },
filter: { status: params.data.status }
});
// Find the max order value for this status
const maxOrder =
existingTodos.length > 0
? Math.max(...existingTodos.map((todo: Todo) => todo.order || 0))
: -1;
// Set the order to be after the last item
params.data.order = maxOrder + 1;
}
return baseDataProvider.create(resource, params);
}
};

The includeStatus prop allows us to show the status field in the create form (where users can choose the initial column) while hiding it in the edit form (since status changes happen via drag-and-drop).

Build the Creation Modal With a Trigger Button

Build a creation modal with a button in the toolbar that opens a dialog with the todo form.

Create TodoCreate.tsx:

src/admin/todos/TodoCreate.tsx
import { CreateBase } from "ra-core";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SimpleForm, FormToolbar } from "@/components/admin";
import { Button } from "@/components/ui/button";
import { SaveButton } from "@/components/admin/form";
import { TodoInputs } from "./TodoInputs";
import { Plus } from "lucide-react";
import { useState } from "react";
interface TodoCreateProps {
defaultValues?: Record<string, unknown>;
}
export const TodoCreate = ({ defaultValues }: TodoCreateProps) => {
const [open, setOpen] = useState(false);
return (
<>
<Button
onClick={() => setOpen(true)}
variant="ghost"
className="cursor-pointer"
>
<Plus className="mr-2 h-4 w-4" />
Create Todo
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Todo</DialogTitle>
</DialogHeader>
<CreateBase
resource="todos"
redirect={false}
mutationOptions={{
onSuccess: () => {
setOpen(false);
},
}}
>
<SimpleForm
toolbar={
<FormToolbar>
<div className="flex flex-row gap-2 justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<SaveButton />
</div>
</FormToolbar>
}
defaultValues={{
status: "todo",
order: 0,
createdAt: new Date().toISOString(),
...defaultValues,
}}
>
<TodoInputs includeStatus />
</SimpleForm>
</CreateBase>
</DialogContent>
</Dialog>
</>
);
};

This component:

  • Uses local state to control the dialog’s open/closed state
  • Leverages React Admin’s CreateBase for form submission and validation
  • Automatically closes the modal on successful creation with mutationOptions.onSuccess
  • Sets sensible defaults: status is “todo” and order is 0 (which will be overridden by our data provider)

Add the Create Button to the Board Toolbar

Add the create button to the toolbar by creating a custom actions component.

Update TodoList.tsx:

src/admin/todos/TodoList.tsx
import { TodoCreate } from "./TodoCreate";
const TodoListActions = () => {
return <TodoCreate />;
};
export const TodoList = ({
disableBreadcrumb,
actions,
filter,
title,
}: TodoListProps = {}) => {
return (
<List
resource="todos"
/* ... */
actions={actions ?? <TodoListActions />}
>
<KanbanBoard />
</List>
);
};

The TodoListActions component provides a clean place to add more toolbar buttons in the future. The create button will now appear in the top-right corner of the Kanban board.

Try it out! Click “Create Todo” and add a new task to the board.

Build the Edit Modal That Opens on Card Click

For editing, create a similar modal that opens when clicking a card. Use React Admin’s EditBase instead of CreateBase, and set mutation mode to “pessimistic” to wait for server confirmation.

Create TodoEdit.tsx:

src/admin/todos/TodoEdit.tsx
import { EditBase, useRecordContext } from "ra-core";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SimpleForm, FormToolbar } from "@/components/admin";
import { Button } from "@/components/ui/button";
import { SaveButton } from "@/components/admin/form";
import type { Todo } from "./todoTypes";
import { TodoInputs } from "./TodoInputs";
export const TodoEdit = ({ open, onClose }: TodoEditProps) => {
const record = useRecordContext<Todo>();
if (!record) return null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Todo</DialogTitle>
</DialogHeader>
<EditBase
resource="todos"
id={record.id}
redirect={false}
mutationMode="pessimistic"
mutationOptions={{
onSuccess: () => {
onClose();
},
}}
>
<SimpleForm
toolbar={
<FormToolbar>
<div className="flex flex-row gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<SaveButton />
</div>
</FormToolbar>
}
>
<TodoInputs />
</SimpleForm>
</EditBase>
</DialogContent>
</Dialog>
);
};
interface TodoEditProps {
open: boolean;
onClose: () => void;
}

Key points:

  • We use useRecordContext to access the current todo data
  • The mutationMode="pessimistic" waits for server confirmation before updating the UI (unlike our optimistic drag-and-drop)
  • The redirect={false} keeps users on the board after saving
  • Notice we don’t pass includeStatus to TodoInputs, so the status field won’t be shown

Open the Edit Modal on Card Click

Update the TodoCard component to open the edit modal when clicked.

Update TodoCard.tsx:

src/admin/todos/TodoCard.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useRecordContext } from "ra-core";
import { useState } from "react";
import { TodoEdit } from "./TodoEdit";
import type { Todo } from "./todoTypes";
export const TodoCard = () => {
const todo = useRecordContext<Todo>();
const [isEditOpen, setIsEditOpen] = useState(false);
if (!todo) return null;
return (
<>
<Card
className="cursor-grab active:cursor-grabbing transition-shadow gap-2"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-drag-handle]")) return;
setIsEditOpen(true);
}}
>
<CardHeader className="px-3">
<h4 className="font-semibold text-sm leading-tight">
{todo.title}
</h4>
</CardHeader>
<CardContent className="px-3 py-0">
<p className="text-xs text-muted-foreground line-clamp-2">
{todo.description}
</p>
</CardContent>
</Card>
<TodoEdit open={isEditOpen} onClose={() => setIsEditOpen(false)} />
</>
);
};

We added:

  • Local state to track whether the edit modal is open
  • An onClick handler on the card that opens the modal (while avoiding conflicts with drag handles)
  • The TodoEdit component that renders at the bottom

The card now has two cursor states: cursor-grab when hovering (for dragging) and clickable for editing.

Test it out! Click any todo card to see the edit dialog appear. Make changes and save - the card will update immediately on the board.

The Kanban board is now feature-complete! We can view, create, edit, and move todos between columns with a smooth, polished user experience.

Conclusion

We’ve built a functional Kanban board with drag-and-drop! Here’s what we covered:

  • Project Setup: Vite, TypeScript, Tailwind CSS, and Shadcn Admin Kit
  • Data Layer: Mock REST API with automatic todo ordering
  • Visual Layout: Three-column board with styled cards
  • Drag and Drop: Smooth interactions with optimistic updates
  • CRUD Operations: Modal-based creation and editing forms

The optimistic updates make the interface feel instant, even though we’re simulating API calls. The drag-and-drop library (@hello-pangea/dnd) also handled all the accessibility concerns.

Where to Go Next

Our board uses an in-memory data provider, but connecting to a real backend takes just a few lines. React Admin supports many data providers:

  • REST APIs: ra-data-simple-rest or ra-data-json-server
  • GraphQL: ra-data-graphql or ra-data-hasura
  • Custom APIs: Implement a custom data provider

Replace fakeRestProvider in dataProvider.ts with the chosen provider.

If we were to continue working on this project, we’d add:

  • Due dates with visual indicators for overdue tasks
  • User assignments with avatars
  • Task priority levels with color coding
  • Activity logs to track todo history
  • Subtasks or checklists
  • Filters and search

Complete Source Code

The full source code for this tutorial is available on GitHub:
github.com/marmelab/shadcn-admin-kit-demo-kanban

Clone it, experiment with it, and adapt it as needed!

Stay Updated

Want to learn more about Shadcn Admin Kit and receive updates on new features, tutorials, and best practices? Subscribe to our newsletter below:

Your registration is confirmed.

Keep up to date

Join our react-admin newsletter for regular updates. No spam ever.

Authors

Jonathan Arnault

Full-stack web developer at marmelab, Jonathan likes to cook and do photography on his spare time.