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
Selected: report.pdf
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. canReorder pins notes.txt to the end, and blocked positions show no preview.
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.
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="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}
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:opacity-45 data-selected:bg-teal-100 before:pointer-events-none before:absolute before:inset-x-3 before:-top-1 before:z-10 before:h-1 before:rounded-full before:bg-teal-500 before:opacity-0 before:ring-4 before:ring-teal-500/15 before:transition-opacity data-drop-before:before:opacity-100 after:pointer-events-none after:absolute after:inset-x-3 after:-bottom-1 after:z-10 after:h-1 after:rounded-full after:bg-teal-500 after:opacity-0 after:ring-4 after:ring-teal-500/15 after:transition-opacity data-drop-after:after:opacity-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"
>
<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>
</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.
To do
Improve search
Add recent queries and clearer results.
Review contrast
Check the new dashboard tokens.
In progress
Design empty states
Cover first-run project screens.
Done
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 border border-zinc-950/10 bg-zinc-100/80 p-2.5 shadow-sm dark:border-white/10 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 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)] focus-within:border-teal-500/50 dark:border-white/10 dark:data-drop-target:bg-teal-950/40"
>
{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:-translate-y-px hover:shadow-md active:cursor-grabbing focus-visible:outline-2 data-dragging:cursor-grabbing data-dragging:scale-[0.98] data-dragging:opacity-45 before:pointer-events-none before:absolute before:inset-x-4 before:-top-1 before:z-10 before:h-1 before:rounded-full before:bg-teal-500 before:opacity-0 before:ring-4 before:ring-teal-500/15 before:transition-opacity data-drop-before:before:opacity-100 after:pointer-events-none after:absolute after:inset-x-4 after:-bottom-1 after:z-10 after:h-1 after:rounded-full after:bg-teal-500 after:opacity-0 after:ring-4 after:ring-teal-500/15 after:transition-opacity data-drop-after:after:opacity-100 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-start [&>[role=gridcell]]:gap-2"
>
<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>
);
}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.
PDF · Updated 12 minutes ago
Figma · Updated yesterday
Markdown · Updated Friday
Selected: Product brief.pdf. ArrowRight reaches links and actions inside a row.
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 and through the row's controls.
- ←
- Moves back toward the row.
- 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
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 ArrowRight; 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.
- 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. |
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.
[data-selected]on GridListItem
- The row is selected.
[data-disabled]on GridListItem
- The row is disabled.
[data-dragging]on GridListItem
- The row is being dragged.
[data-drop-target]on GridList
- The dragged row will be inserted in this list, including when it is empty.
[data-drop-before]on GridListItem
- The dragged row will drop before this row; style it as the drop preview.
[data-drop-after]on GridListItem
- The dragged row will drop after this row; style it as the drop preview.
:focus-visibleon GridListItem and its controls
- The row or a control in it has keyboard focus.