Navigation
Grid List
A list of rows where each row can hold its own controls.
When to use it: Use it when rows need buttons or fields inside them; use ListBox for plain options.
Example
import { useState } from "react";
import { Button, GridList, GridListItem } from "@comp0/react";
export function Example() {
const [selected, setSelected] = useState("report.pdf");
return (
<div className="flex max-w-sm flex-col gap-2">
<GridList
aria-label="Files"
className="rounded border border-zinc-950/10 p-1 dark:border-white/10"
value={selected}
onChange={setSelected}
>
{["report.pdf", "photos.zip"].map((name) => (
<GridListItem
key={name}
value={name}
textValue={name}
className="cursor-pointer rounded px-2 py-1.5 outline-teal-600 focus-visible:outline-2 data-selected:bg-teal-100 dark:outline-teal-400 dark:data-selected:bg-teal-950 [&>[role=gridcell]]:flex [&>[role=gridcell]]:items-center [&>[role=gridcell]]:justify-between [&>[role=gridcell]]:gap-3"
>
<span className="text-base text-zinc-800 sm:text-sm dark:text-zinc-100">{name}</span>
<Button className="rounded border border-zinc-950/10 px-2 py-1 text-base text-zinc-700 outline-teal-600 focus-visible:outline-2 sm:text-sm dark:border-white/10 dark:text-zinc-200 dark:outline-teal-400">
Share
</Button>
</GridListItem>
))}
</GridList>
<p className="text-base text-zinc-600 sm:text-sm dark:text-zinc-400">Selected: {selected}</p>
</div>
);
}Reorder a list
onReorder makes rows movable; drag any non-interactive part of a row, use the labelled grip as an explicit affordance, or press Alt+Arrow. draggable={false} excludes pinned notes.txt from reordering, while canReorder keeps other rows from displacing it.
import { useState } from "react";
import { GridList, GridListDragHandle, GridListItem } from "@comp0/react";
function DragGrip() {
return (
<svg aria-hidden="true" viewBox="0 0 12 18" fill="currentColor" className="size-4">
<circle cx="3" cy="3" r="1.25" />
<circle cx="9" cy="3" r="1.25" />
<circle cx="3" cy="9" r="1.25" />
<circle cx="9" cy="9" r="1.25" />
<circle cx="3" cy="15" r="1.25" />
<circle cx="9" cy="15" r="1.25" />
</svg>
);
}
export function Example() {
const [files, setFiles] = useState(["report.pdf", "photos.zip", "archive.tar", "notes.txt"]);
return (
<div className="flex max-w-sm flex-col gap-2">
<GridList
aria-label="Files"
className="flex flex-col rounded border border-zinc-950/10 p-1 dark:border-white/10"
onReorder={setFiles}
canReorder={(next) => next.at(-1) === "notes.txt"}
>
{files.map((name) => (
<GridListItem
key={name}
value={name}
textValue={name}
draggable={name !== "notes.txt"}
data-pinned={name === "notes.txt" || undefined}
className="relative cursor-grab select-none rounded-md px-2 py-1.5 outline-teal-600 transition-colors hover:bg-zinc-950/5 active:cursor-grabbing focus-visible:outline-2 data-dragging:cursor-grabbing data-dragging:bg-teal-500/10 data-dragging:outline data-dragging:outline-1 data-dragging:outline-dashed data-dragging:outline-teal-500 data-drag-previewing:order-1 data-drop-before:order-2 data-pinned:cursor-default data-selected:bg-teal-100 dark:outline-teal-400 dark:hover:bg-white/5 dark:data-selected:bg-teal-950 [&>[role=gridcell]]:flex [&>[role=gridcell]]:items-center [&>[role=gridcell]]:gap-2 [[data-drop-after]~&:not([data-drag-previewing])]:order-2 [[data-drop-before]~&:not([data-drag-previewing])]:order-2"
>
<GridListDragHandle
aria-label={`Reorder ${name}`}
className="cursor-grab rounded p-1 text-zinc-400 outline-teal-600 hover:bg-zinc-950/5 hover:text-zinc-700 active:cursor-grabbing focus-visible:outline-2 dark:text-zinc-500 dark:outline-teal-400 dark:hover:bg-white/5 dark:hover:text-zinc-200"
>
<DragGrip />
</GridListDragHandle>
<span className="text-base text-zinc-800 sm:text-sm dark:text-zinc-100">{name}</span>
{name === "notes.txt" && (
<span className="ml-auto rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
Pinned
</span>
)}
</GridListItem>
))}
</GridList>
<p className="text-base text-zinc-600 sm:text-sm dark:text-zinc-400">
Drag any non-interactive part of a row, or press Alt+Arrow. The named grip is an explicit
drag affordance. notes.txt always stays last.
</p>
</div>
);
}Kanban board
GridListReorderGroup owns one controlled order for all columns. Drag cards for precise placement, use Alt+Arrow within a column, or activate the arrow buttons to move without dragging.
import { useState } from "react";
import {
GridList,
GridListDragHandle,
GridListItem,
GridListMoveButton,
GridListReorderGroup,
} from "@comp0/react";
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
const stages = [
{ name: "todo", label: "To do", accentClassName: "bg-amber-500 ring-amber-500/15" },
{ name: "doing", label: "In progress", accentClassName: "bg-sky-500 ring-sky-500/15" },
{ name: "done", label: "Done", accentClassName: "bg-emerald-500 ring-emerald-500/15" },
];
const taskById: Record<
string,
{
title: string;
detail: string;
label: string;
labelClassName: string;
progress: string;
owner: string;
}
> = {
search: {
title: "Improve search",
detail: "Add recent queries and clearer results.",
label: "Research",
labelClassName: "bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-300",
progress: "3/5",
owner: "AM",
},
contrast: {
title: "Review contrast",
detail: "Check the new dashboard tokens.",
label: "Design",
labelClassName: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
progress: "4/4",
owner: "RH",
},
empty: {
title: "Design empty states",
detail: "Cover first-run project screens.",
label: "Product",
labelClassName: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
progress: "1/3",
owner: "SL",
},
};
const initialOrder: Record<string, string[]> = {
todo: ["search", "contrast"],
doing: ["empty"],
done: [],
};
function DragGrip() {
return (
<svg aria-hidden="true" viewBox="0 0 12 18" fill="currentColor" className="size-4">
<circle cx="3" cy="3" r="1.25" />
<circle cx="9" cy="3" r="1.25" />
<circle cx="3" cy="9" r="1.25" />
<circle cx="9" cy="9" r="1.25" />
<circle cx="3" cy="15" r="1.25" />
<circle cx="9" cy="15" r="1.25" />
</svg>
);
}
export function Example() {
const [order, setOrder] = useState(initialOrder);
return (
<GridListReorderGroup value={order} onChange={setOrder}>
<div className="grid w-full grid-cols-1 gap-3 md:grid-cols-3">
{stages.map((stage, index) => {
const previous = stages[index - 1];
const next = stages[index + 1];
const tasks = order[stage.name]!;
return (
<section
key={stage.name}
aria-labelledby={`${stage.name}-title`}
className="min-w-0 rounded-xl bg-zinc-100/80 p-2.5 dark:bg-zinc-900/80"
>
<header className="mb-2 flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className={`size-2 rounded-full ring-4 ${stage.accentClassName}`}
/>
<h3
id={`${stage.name}-title`}
className="text-sm font-semibold text-zinc-900 dark:text-zinc-100"
>
{stage.label}
</h3>
</div>
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium tabular-nums text-zinc-600 shadow-sm ring-1 ring-zinc-950/10 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-white/10">
{tasks.length}
</span>
</header>
<GridList
name={stage.name}
aria-labelledby={`${stage.name}-title`}
className="flex min-h-40 flex-col gap-2 rounded-lg border border-dashed border-zinc-950/10 p-1 outline-none transition after:pointer-events-none after:order-1 after:hidden after:min-h-9 after:items-center after:justify-center after:rounded-lg after:border after:border-dashed after:border-teal-500 after:bg-teal-500/10 after:px-2 after:text-xs after:font-medium after:text-teal-700 after:content-[attr(data-drop-preview)] focus-within:border-teal-500/50 data-drop-target:border-teal-500 data-drop-target:bg-teal-50/80 data-drop-target:shadow-[inset_0_0_0_1px_var(--color-teal-500)] dark:border-white/10 dark:after:text-teal-300 dark:data-drop-target:bg-teal-950/40 [&[data-drop-target]:not(:has([data-drag-previewing]))]:after:flex"
>
{tasks.map((taskId) => {
const task = taskById[taskId]!;
return (
<GridListItem
key={taskId}
value={taskId}
textValue={task.title}
className="relative cursor-grab select-none rounded-lg border border-zinc-950/10 bg-white p-2.5 shadow-sm outline-teal-600 transition hover:shadow-md active:cursor-grabbing focus-visible:outline-2 data-dragging:cursor-grabbing data-dragging:border-dashed data-dragging:border-teal-500 data-dragging:shadow-none data-drop-before:order-2 dark:border-white/10 dark:bg-zinc-950 dark:outline-teal-400 dark:data-dragging:border-teal-400 [&>[role=gridcell]]:grid [&>[role=gridcell]]:grid-cols-[auto_1fr_auto] [&>[role=gridcell]]:items-start [&>[role=gridcell]]:gap-2 [[data-drop-after]~&:not([data-drag-previewing])]:order-2 [[data-drop-before]~&:not([data-drag-previewing])]:order-2 [[role=grid][data-drop-target]_&[data-drag-previewing]]:order-1 [[role=grid]:not([data-drop-target])_&[data-drag-previewing]]:opacity-40"
>
<GridListDragHandle
aria-label={`Drag ${task.title}`}
className="cursor-grab rounded p-1 text-zinc-400 outline-teal-600 hover:bg-zinc-950/5 hover:text-zinc-700 active:cursor-grabbing focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-white/5 dark:hover:text-zinc-200"
>
<DragGrip />
</GridListDragHandle>
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{task.title}
</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{task.detail}
</p>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${task.labelClassName}`}
>
{task.label}
</span>
<span
aria-label={`${task.progress} subtasks complete`}
className="text-xs tabular-nums text-zinc-500 dark:text-zinc-400"
>
{task.progress}
</span>
</div>
<span
aria-label={`Assigned to ${task.owner}`}
className="grid size-6 shrink-0 place-items-center rounded-full bg-zinc-900 text-[10px] font-semibold text-white dark:bg-zinc-100 dark:text-zinc-900"
>
{task.owner}
</span>
</div>
</div>
<div className="flex gap-1">
{previous && (
<GridListMoveButton
to={previous.name}
aria-label={`Move ${task.title} to ${previous.label}`}
className="cursor-pointer rounded p-1 text-zinc-400 outline-teal-600 hover:bg-zinc-950/5 hover:text-zinc-700 focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-white/5 dark:hover:text-zinc-200"
>
<ArrowLeftIcon className="size-4" aria-hidden="true" />
</GridListMoveButton>
)}
{next && (
<GridListMoveButton
to={next.name}
aria-label={`Move ${task.title} to ${next.label}`}
className="cursor-pointer rounded p-1 text-zinc-400 outline-teal-600 hover:bg-zinc-950/5 hover:text-zinc-700 focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-white/5 dark:hover:text-zinc-200"
>
<ArrowRightIcon className="size-4" aria-hidden="true" />
</GridListMoveButton>
)}
</div>
</GridListItem>
);
})}
</GridList>
</section>
);
})}
</div>
</GridListReorderGroup>
);
}Transfer list
Compose two named Grid Lists for a transfer list. Checkboxes provide bulk selection, ordinary buttons move the selected rows, and each row retains the group's pointer, keyboard, and direct move paths.
import { useState } from "react";
import {
Button,
Checkbox,
GridList,
GridListDragHandle,
GridListItem,
GridListMoveButton,
GridListReorderGroup,
} from "@comp0/react";
import { ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "@heroicons/react/16/solid";
const collections = [
{ name: "available", label: "Available", destination: "chosen" },
{ name: "chosen", label: "Chosen", destination: "available" },
] as const;
const frameworkById: Record<string, { name: string; description: string }> = {
react: { name: "React", description: "Component library" },
vue: { name: "Vue", description: "Progressive framework" },
svelte: { name: "Svelte", description: "Compiler-based framework" },
solid: { name: "Solid", description: "Fine-grained reactivity" },
angular: { name: "Angular", description: "Application platform" },
};
const initialOrder: Record<string, string[]> = {
available: ["vue", "svelte", "solid", "angular"],
chosen: ["react"],
};
function DragGrip() {
return (
<svg aria-hidden="true" viewBox="0 0 12 18" fill="currentColor" className="size-4">
<circle cx="3" cy="3" r="1.25" />
<circle cx="9" cy="3" r="1.25" />
<circle cx="3" cy="9" r="1.25" />
<circle cx="9" cy="9" r="1.25" />
<circle cx="3" cy="15" r="1.25" />
<circle cx="9" cy="15" r="1.25" />
</svg>
);
}
export function Example() {
const [order, setOrder] = useState(initialOrder);
const [selectedFrameworks, setSelectedFrameworks] = useState<string[]>([]);
const moveSelected = (source: string, destination: string) => {
const movingFrameworks = order[source]!.filter((frameworkId) =>
selectedFrameworks.includes(frameworkId),
);
if (movingFrameworks.length === 0) return;
setOrder((current) => ({
...current,
[source]: current[source]!.filter((frameworkId) => !movingFrameworks.includes(frameworkId)),
[destination]: [...current[destination]!, ...movingFrameworks],
}));
setSelectedFrameworks((current) =>
current.filter((frameworkId) => !movingFrameworks.includes(frameworkId)),
);
};
return (
<GridListReorderGroup value={order} onChange={setOrder}>
<div className="grid w-full max-w-3xl grid-cols-1 items-center gap-3 md:grid-cols-[1fr_auto_1fr]">
{collections.map((collection, index) => (
<div key={collection.name} className="contents">
<section aria-labelledby={`${collection.name}-title`} className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3 px-1">
<h3
id={`${collection.name}-title`}
className="text-sm font-semibold text-zinc-900 dark:text-zinc-100"
>
{collection.label}
</h3>
<span className="text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{order[collection.name]!.length}
</span>
</div>
<GridList
name={collection.name}
aria-labelledby={`${collection.name}-title`}
className="flex min-h-64 flex-col gap-1.5 rounded-lg border border-dashed border-zinc-950/15 p-1.5 outline-none after:pointer-events-none after:order-1 after:hidden after:min-h-12 after:items-center after:justify-center after:rounded-lg after:border after:border-dashed after:border-teal-500 after:bg-teal-500/10 after:px-2 after:text-xs after:font-medium after:text-teal-700 after:content-[attr(data-drop-preview)] focus-within:border-teal-500/50 data-drop-target:border-teal-500 dark:border-white/15 dark:after:text-teal-300 [&[data-drop-target]:not(:has([data-drag-previewing]))]:after:flex"
>
{order[collection.name]!.map((frameworkId) => {
const framework = frameworkById[frameworkId]!;
const checked = selectedFrameworks.includes(frameworkId);
return (
<GridListItem
key={frameworkId}
value={frameworkId}
textValue={framework.name}
className="cursor-grab select-none rounded-md border border-zinc-950/10 bg-white shadow-sm outline-teal-600 focus-visible:outline-2 data-dragging:border-dashed data-dragging:border-teal-500 data-dragging:shadow-none data-drop-before:order-2 dark:border-white/10 dark:bg-zinc-950 dark:outline-teal-400 [&>[role=gridcell]]:grid [&>[role=gridcell]]:grid-cols-[auto_1fr_auto] [&>[role=gridcell]]:items-center [&>[role=gridcell]]:gap-2 [&>[role=gridcell]]:p-2 [[data-drop-after]~&:not([data-drag-previewing])]:order-2 [[data-drop-before]~&:not([data-drag-previewing])]:order-2 [[role=grid][data-drop-target]_&[data-drag-previewing]]:order-1 [[role=grid]:not([data-drop-target])_&[data-drag-previewing]]:opacity-40"
>
<GridListDragHandle
aria-label={`Drag ${framework.name}`}
className="cursor-grab rounded p-0.5 text-zinc-400 outline-teal-600 hover:bg-zinc-950/5 hover:text-zinc-700 focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-white/5 dark:hover:text-zinc-200"
>
<DragGrip />
</GridListDragHandle>
<Checkbox
checked={checked}
onChange={(nextChecked) =>
setSelectedFrameworks((current) => {
if (nextChecked) return [...current, frameworkId];
return current.filter((value) => value !== frameworkId);
})
}
className="group flex min-w-0 cursor-pointer items-center gap-2 rounded outline-none"
>
<span className="grid size-4 shrink-0 place-items-center rounded-sm border border-zinc-950/20 bg-white text-white ring-2 ring-transparent group-data-focus-visible:ring-teal-600 group-data-checked:border-teal-600 group-data-checked:bg-teal-600 dark:border-white/20 dark:bg-zinc-900 dark:group-data-focus-visible:ring-teal-400 dark:group-data-checked:border-teal-400 dark:group-data-checked:bg-teal-400">
{checked && <CheckIcon className="size-3" aria-hidden="true" />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
{framework.name}
</span>
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
{framework.description}
</span>
</span>
</Checkbox>
<GridListMoveButton
to={collection.destination}
aria-label={`Move ${framework.name} to ${collection.destination}`}
className="cursor-pointer rounded p-1 text-zinc-400 outline-teal-600 hover:bg-zinc-950/5 hover:text-zinc-700 focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-white/5 dark:hover:text-zinc-200"
>
{collection.name === "available" ? (
<ArrowRightIcon className="size-4" aria-hidden="true" />
) : (
<ArrowLeftIcon className="size-4" aria-hidden="true" />
)}
</GridListMoveButton>
</GridListItem>
);
})}
</GridList>
</section>
{index === 0 && (
<div className="flex justify-center gap-2 md:flex-col">
<Button
disabled={
!order.available!.some((frameworkId) =>
selectedFrameworks.includes(frameworkId),
)
}
onClick={() => moveSelected("available", "chosen")}
className="select-none rounded border border-zinc-950/10 px-2.5 py-2 text-sm text-zinc-700 outline-teal-600 hover:bg-zinc-950/5 focus-visible:outline-2 disabled:opacity-40 dark:border-white/10 dark:text-zinc-200 dark:outline-teal-400 dark:hover:bg-white/5"
>
Add selected <ArrowRightIcon className="ml-1 inline size-4" aria-hidden="true" />
</Button>
<Button
disabled={
!order.chosen!.some((frameworkId) => selectedFrameworks.includes(frameworkId))
}
onClick={() => moveSelected("chosen", "available")}
className="select-none rounded border border-zinc-950/10 px-2.5 py-2 text-sm text-zinc-700 outline-teal-600 hover:bg-zinc-950/5 focus-visible:outline-2 disabled:opacity-40 dark:border-white/10 dark:text-zinc-200 dark:outline-teal-400 dark:hover:bg-white/5"
>
<ArrowLeftIcon className="mr-1 inline size-4" aria-hidden="true" /> Remove
selected
</Button>
</div>
)}
</div>
))}
</div>
</GridListReorderGroup>
);
}File rows with actions
Use Grid List when a selectable row needs its own link and controls. ArrowRight enters those interactive elements without turning the collection into a plain list of options.
import { useState } from "react";
import { Button, GridList, GridListItem } from "@comp0/react";
const files = [
{ name: "Product brief.pdf", detail: "PDF · Updated 12 minutes ago", access: "Team" },
{ name: "Homepage concepts.fig", detail: "Figma · Updated yesterday", access: "Design" },
{ name: "Launch checklist.md", detail: "Markdown · Updated Friday", access: "Private" },
];
export function Example() {
const [selected, setSelected] = useState(files[0]!.name);
const [sharedFile, setSharedFile] = useState<string>();
let actionMessage = "ArrowRight reaches links and actions inside a row.";
if (sharedFile) actionMessage = `Share link created for ${sharedFile}.`;
return (
<div className="flex max-w-xl flex-col gap-2">
<GridList
aria-label="Recent files"
className="rounded-xl border border-zinc-950/10 bg-white p-1 shadow-sm dark:border-white/10 dark:bg-zinc-900"
value={selected}
onChange={setSelected}
>
{files.map((file) => (
<GridListItem
key={file.name}
value={file.name}
textValue={file.name}
className="cursor-pointer rounded-lg px-3 py-2.5 outline-teal-600 transition-colors hover:bg-zinc-950/[0.03] focus-visible:outline-2 data-selected:bg-teal-50 dark:hover:bg-white/[0.04] dark:outline-teal-400 dark:data-selected:bg-teal-950/60 [&>[role=gridcell]]:grid [&>[role=gridcell]]:grid-cols-[minmax(0,1fr)_auto] [&>[role=gridcell]]:items-center [&>[role=gridcell]]:gap-3"
>
<div className="min-w-0">
<a
href={`#${file.name.toLowerCase().replaceAll(" ", "-").replaceAll(".", "")}`}
className="block cursor-pointer truncate text-sm font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 outline-teal-600 hover:decoration-zinc-900 focus-visible:outline-2 dark:text-zinc-100 dark:decoration-zinc-600 dark:outline-teal-400 dark:hover:decoration-zinc-100"
>
{file.name}
</a>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">{file.detail}</p>
</div>
<div className="flex items-center gap-2">
<span className="hidden rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600 sm:inline dark:bg-zinc-800 dark:text-zinc-300">
{file.access}
</span>
<Button
onClick={() => setSharedFile(file.name)}
className="cursor-pointer rounded-md border border-zinc-950/10 px-2.5 py-1.5 text-sm font-medium text-zinc-700 outline-teal-600 hover:bg-zinc-950/5 focus-visible:outline-2 dark:border-white/10 dark:text-zinc-200 dark:outline-teal-400 dark:hover:bg-white/5"
>
Share
</Button>
</div>
</GridListItem>
))}
</GridList>
<p className="text-base text-zinc-600 sm:text-sm dark:text-zinc-400">
Selected: {selected}. {actionMessage}
</p>
</div>
);
}Anatomy
Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.
A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.
GridListReorderGroup
Context-only owner for one atomic order shared by multiple named lists. Does not add a DOM element.
GridList
Grid container and the list's single tab stop. Owns a DOM element.
GridListItem
Selectable row that can hold its own controls. Owns a DOM element.
GridListDragHandleOptional
Optional labelled button that gives a row an explicit drag affordance; pointer drags can also start on non-interactive parts of the row body. Owns a DOM element.
GridListMoveButtonOptional
Optional click, tap, and keyboard path for appending a row to another named list. Owns a DOM element.
Step by step
- 1
Add the main part
Start GridList with an aria-label.
- 2
Add the supporting parts
Add a GridListItem with a value for each row.
- 3
Make the behavior clear
For a board, wrap named lists in GridListReorderGroup and add GridListMoveButton controls alongside an optional labelled drag grip.
Exampletsx <GridList aria-label="Files"> <GridListItem value="report">report.pdf</GridListItem> </GridList>;
Keyboard
- ↓
- Moves to the next row.
- ↑
- Moves to the previous row.
- ←→
- Moves into or out of row controls; inline direction mirrors in RTL.
- Home
- Moves to the first row.
- End
- Moves to the last row.
- ↵
- Selects the focused row.
- Space
- Selects the focused row.
- Alt↑
- Moves the row up. · while reorderable
- Alt↓
- Moves the row down. · while reorderable
- ↵
- Moves the row to the named destination. · on GridListMoveButton
- Space
- Moves the row to the named destination. · on GridListMoveButton
- ↵
- Starts moving the row, then drops it at the chosen position. · on GridListDragHandle
- ↑↓
- Chooses a position in the list while moving. · on GridListDragHandle
- ←→
- Chooses a position in a neighboring list while moving. · on GridListDragHandle, in a group
- Esc
- Cancels the move. · on GridListDragHandle
Forms and accessibility
Selection does not create a native form value; mirror it into a hidden input when a form needs it.
Accessibility checklist
- Give the grid an aria-label when it has no visible heading.
- Keep row focus and row selection visibly distinct.
- Reach row controls with the inline-forward arrow: ArrowRight in LTR and ArrowLeft in RTL. Do not add extra tab stops.
- Reordering never requires a pointer: Alt+Arrow moves the focused row and a live region announces its new position.
- Enter on the drag handle starts a keyboard move: arrows choose a position — across lists in a group — with the same drop preview pointer drags show, Enter drops, Escape cancels, and every step is announced.
- For cross-list moves, render GridListMoveButton controls so the same operation works with a click, tap, Enter, or Space without dragging.
API reference
import { GridList, GridListDragHandle, GridListItem, GridListMoveButton, GridListReorderGroup } from "@comp0/react";GridListReorderGroup
Context onlyContext-only owner for one atomic order shared by multiple named lists.
| Prop | Type | Description |
|---|---|---|
value | Record<string, readonly string[]> | Current row order keyed by GridList name; row values are unique across the group. |
onChange | (value, move) => void | Receives the complete next order and one move descriptor for local or cross-list moves. |
pending | boolean | Keeps a proposed move locked while an asynchronous owner decides; retaining value after pending clears rejects it. |
canMove | (value, move) => boolean | Vetoes a proposed complete order for drag-and-drop, Alt+Arrow reorders, and GridListMoveButton moves. |
GridList
DOM elementGrid container and the list's single tab stop.
| Prop | Type | Description |
|---|---|---|
aria-label | string | Names the grid; nothing labels it automatically. |
name | string | Column key required when the list is inside GridListReorderGroup. |
value | string | Controlled selected row. |
defaultValue | string | Initial selected row. |
onChange | (value: string) => void | Receives the next selected row. |
onReorder | (values: string[]) => void | Receives the full new row order; providing it makes rows draggable and movable with Alt+Arrow keys, with moves announced to screen readers. |
canReorder | (values: string[], moved: string) => boolean | Vetoes a proposed order: blocked drop positions show no drop preview and blocked keyboard moves are announced but not applied. |
GridListItem
DOM elementSelectable row that can hold its own controls.
| Prop | Type | Description |
|---|---|---|
value | string | This row’s selection key. |
disabled | boolean | Disables the row. |
draggable | boolean | Set false to keep the row selectable while excluding it from reordering. |
textValue | string | Overrides the text crawled from children when markup makes it ambiguous. |
GridListDragHandle
OptionalDOM elementOptional labelled button that gives a row an explicit drag affordance; pointer drags can also start on non-interactive parts of the row body.
GridListMoveButton
OptionalDOM elementOptional click, tap, and keyboard path for appending a row to another named list.
| Prop | Type | Description |
|---|---|---|
to | string | Destination GridList name. |
aria-label | string | Names the row and destination for an icon-only control. |
Style hooks
Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.
GridList
| Style hook | Meaning |
|---|---|
[data-drop-target] | The dragged row will be inserted in this list, including when it is empty. |
[data-drop-preview] | The dragged row label while this list is the drop target, for an insertion slot between the order bands. |
GridListItem
| Style hook | Meaning |
|---|---|
[data-selected] | The row is selected. |
[data-disabled] | The row is disabled. |
[data-dragging] | The row is being dragged. |
[data-drag-previewing] | The dragged row has a valid destination; order: 1 in a flex column moves it there. |
[data-drop-before] | The dragged row will drop before this row; order: 2 on it and its later siblings completes the preview. |
[data-drop-after] | The dragged row will drop after this row; order: 2 on its later siblings completes the preview. |
[data-drop-preview] | The dragged row label, available for an in-flow destination preview. |
:focus-visible | The row or a control in it has keyboard focus. |
GridListDragHandle
| Style hook | Meaning |
|---|---|
:focus-visible | The row or a control in it has keyboard focus. |
GridListMoveButton
| Style hook | Meaning |
|---|---|
:focus-visible | The row or a control in it has keyboard focus. |