Skip to content

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.

On this page

Example

Loading example…
Grid List.tsxtsx
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.

Loading example…
grid-list.reorder.tsxtsx
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.

Loading example…
grid-list.kanban.tsxtsx
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.

Loading example…
grid-list.transfer-list.tsxtsx
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.

Loading example…
grid-list.files.tsxtsx
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.

  1. GridListReorderGroup

    Context-only owner for one atomic order shared by multiple named lists. Does not add a DOM element.

  2. GridList

    Grid container and the list's single tab stop. Owns a DOM element.

  3. GridListItem

    Selectable row that can hold its own controls. Owns a DOM element.

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

  5. GridListMoveButtonOptional

    Optional click, tap, and keyboard path for appending a row to another named list. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Start GridList with an aria-label.

  2. 2

    Add the supporting parts

    Add a GridListItem with a value for each row.

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

Importtsx
import { GridList, GridListDragHandle, GridListItem, GridListMoveButton, GridListReorderGroup } from "@comp0/react";

GridListReorderGroup

Context only

Context-only owner for one atomic order shared by multiple named lists.

PropTypeDescription
valueRecord<string, readonly string[]>Current row order keyed by GridList name; row values are unique across the group.
onChange(value, move) => voidReceives the complete next order and one move descriptor for local or cross-list moves.
pendingbooleanKeeps a proposed move locked while an asynchronous owner decides; retaining value after pending clears rejects it.
canMove(value, move) => booleanVetoes a proposed complete order for drag-and-drop, Alt+Arrow reorders, and GridListMoveButton moves.

GridList

DOM element

Grid container and the list's single tab stop.

PropTypeDescription
aria-labelstringNames the grid; nothing labels it automatically.
namestringColumn key required when the list is inside GridListReorderGroup.
valuestringControlled selected row.
defaultValuestringInitial selected row.
onChange(value: string) => voidReceives the next selected row.
onReorder(values: string[]) => voidReceives 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) => booleanVetoes a proposed order: blocked drop positions show no drop preview and blocked keyboard moves are announced but not applied.

GridListItem

DOM element

Selectable row that can hold its own controls.

PropTypeDescription
valuestringThis row’s selection key.
disabledbooleanDisables the row.
draggablebooleanSet false to keep the row selectable while excluding it from reordering.
textValuestringOverrides the text crawled from children when markup makes it ambiguous.

GridListDragHandle

OptionalDOM element

Optional 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 element

Optional click, tap, and keyboard path for appending a row to another named list.

PropTypeDescription
tostringDestination GridList name.
aria-labelstringNames 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 hookMeaning
[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 hookMeaning
[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-visibleThe row or a control in it has keyboard focus.

GridListDragHandle

Style hookMeaning
:focus-visibleThe row or a control in it has keyboard focus.

GridListMoveButton

Style hookMeaning
:focus-visibleThe row or a control in it has keyboard focus.

Keep exploring