shadcn.io is an independent project built on shadcn/ui
Rows sit in groups, one group per status, and move up and down between them by drag or by keyboard.
Use it for a priority list or tasks by status in a narrow panel, where columns side by side wouldn't fit. Stages people scan across belong on a Kanban board, and rows you sort by a column belong in a Table. In React, the list writes its own pointer and key handling, so there is no drag-and-drop package to install, and Base UI isn't involved in the move.
Installation
Anatomy

ListProvider
└── ListGroup
├── ListHeader
└── ListItems
└── ListItemUsage
import {
ListGroup, ListHeader, ListItem, ListItems, ListProvider,
} from "@/components/ui/list";const statuses = [
{ color: "var(--muted-foreground)", id: "todo", name: "To Do" },
{ color: "var(--primary)", id: "done", name: "Done" },
];
const [tasks, setTasks] = useState([
{ id: "1", name: "Research competitors", status: "todo" },
{ id: "2", name: "Set up the project", status: "done" },
]);
<ListProvider onValueChange={setTasks} value={tasks}>
{statuses.map((status) => (
<ListGroup id={status.id} key={status.id}>
<ListHeader color={status.color} name={status.name} />
<ListItems>
{tasks
.filter((task) => task.status === status.id)
.map((task) => (
<ListItem id={task.id} key={task.id} name={task.name} />
))}
</ListItems>
</ListGroup>
))}
</ListProvider>The items are one flat array: each has an id, a name and the status of the group it sits in, and their order in the array is their order in each group. A move hands the whole array back through onValueChange, so the list is always controlled.
Accessibility
Each row is focusable and says how to move it. A polite live region announces every pick-up, step, drop and cancel with the row's name, its group (the header's text) and its position.
- Space
- Pick up the focused row, or drop the row you are holding.
- Enter
- Pick up the focused row, or drop the row you are holding.
- Arrow Up
- Move the held row up one place; from the top of a group, to the end of the group above.
- Arrow Down
- Move the held row down one place; from the end of a group, to the top of the group below.
- Home
- Move the held row to the top of its group.
- End
- Move the held row to the end of its group.
- EscEscape
- Cancel the move and put the row back where it was picked up.
- Tab
- Drop the held row where it is and move focus on.
Demos
Custom Items
A support queue grouped by priority, each row with a grip icon, its title, a Badge and the owner's avatar, and a count in each header. ListItem takes any children in place of its name, and ListHeader takes children in place of its dot and name. The whole row is still the handle, so the grip is only a hint; links, buttons and fields inside a row keep their own clicks.
Move Events
The line under the list shows what onMoveStart and onMoveEnd report: the row's id, where it came from, and where it went to, which is null when the move was cancelled. Both fire for the pointer and the keyboard. Use them to save a move to your server, and keep onValueChange for the array itself.
Scrolling
Ten roadmap items in a list with h-80 and overflow-y-auto on ListProvider, so the list scrolls inside its own frame. Drag a row toward the top or bottom edge and the list scrolls under it; a list without a height scrolls the page instead. Give the list a height when it shares a panel with other content.
FAQ
What data does the List need?
One array, value, with every item's own id, a name and the status it sits under. Each ListGroup takes the id of one status, and you render that status's rows inside it. The order of the items in the array is their order within each group.
How do I save a new order in React?
onValueChange gets the new array after every move: set your state from it, and the list redraws. To send the move to a server, read onMoveEnd, which says which row moved, from where and to where, once per drop. The Move Events demo prints both.
Can people reorder the List from the keyboard?
Yes. Tab to a row, press Space or Enter to pick it up, and move it with the up and down arrows: past the end of a group it carries on into the next one. Press Space or Enter to drop it, or Escape to put it back. A live region announces each step with the group and the position.
Why does a dragged row only move up and down?
A list has one direction, so the copy under the pointer keeps its row's width and place and follows the pointer up and down only. Where the pointer wanders sideways doesn't matter: the drop is read from the row's own column.
How do I show a count or an action in a group header?
Pass children to ListHeader: they replace the colored dot and the name, as the Custom Items demo does with a count. The header's text names the group in announcements, so keep the status's name in it. color takes any CSS color, so var(--primary) follows the theme.
Should I use List or Kanban?
Use List in a narrow space, or when the groups read best one under another. Use Kanban when stages sit side by side and people move work across them. Both take the same kind of flat array and move the same way.
API Reference
ListProvider
valueT[]
The items, in order. Render each group's rows from it.
onValueChange?(value: T[]) → void
Called with every item, in order, after each move. Set value from it.
- Parameters
value– T[]- Returns
void
onMoveEnd?(details: ListMoveDetails) → void
Called when an item is dropped, or its move is cancelled (to is null).
- Type
(details: ListMoveDetails) → void- Parameters
details– { from: ListSpot; id: string; to: ListSpot | null }- Returns
void
onMoveStart?(details: ListMoveDetails) → void
Called when an item is picked up, by the pointer or the keyboard.
- Type
(details: ListMoveDetails) → void- Parameters
details– { from: ListSpot; id: string; to: ListSpot | null }- Returns
void
className?string
Merged with the part's classes.
ListGroup
idstring
The id of the status this group holds: items with that status.
className?string
Merged with the part's classes.
ListHeader
<div> data-slot="list-header"
color?string
The status's color: a dot before the name. Any CSS color, a theme variable included.
name?string
The status's name, when the header has no children.
className?string
Merged with the part's classes.
ListItems
<div> data-slot="list-items"
className?string
Merged with the part's classes.
ListItem
data-slot="list-item"
idstring
The item's id.
namestring
The item's name: the row's content when it has no children.
className?string
Merged with the part's classes.
data-drop?stringdata-held?string
Present when held is true.
data-lifted?stringdata-list-item?string
Mirrors id.










