{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"data-table","type":"registry:component","title":"Data table","description":"Sorts, selects and bulk-acts on rows without losing its place.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/data-table.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Status = \"ready\" | \"building\" | \"error\" | \"queued\";\n\nexport type Deployment = {\n  id: string;\n  name: string;\n  status: Status;\n  branch: string;\n  // Seconds, so sorting compares numbers rather than formatted strings.\n  duration: number;\n  date: string;\n};\n\ntype Key = \"name\" | \"status\" | \"branch\" | \"duration\" | \"date\";\ntype Sort = { key: Key; dir: \"asc\" | \"desc\" } | null;\n\nconst COLUMNS: { key: Key; label: string; numeric?: boolean }[] = [\n  { key: \"name\", label: \"Project\" },\n  { key: \"status\", label: \"Status\" },\n  { key: \"branch\", label: \"Branch\" },\n  { key: \"duration\", label: \"Duration\", numeric: true },\n  { key: \"date\", label: \"Deployed\", numeric: true },\n];\n\n// Orders by how much attention a status needs, not alphabetically.\nconst STATUS_RANK: Record<Status, number> = {\n  error: 0,\n  building: 1,\n  queued: 2,\n  ready: 3,\n};\nconst STATUS_LABEL: Record<Status, string> = {\n  ready: \"Ready\",\n  building: \"Building\",\n  error: \"Error\",\n  queued: \"Queued\",\n};\n\n// No bounce: rows that overshoot would briefly sit in the wrong order.\nconst REORDER = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\nconst formatDuration = (s: number) =>\n  s === 0\n    ? \"0s\"\n    : s < 60\n      ? `${s}s`\n      : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s`;\n// Fixed to UTC so the server and every viewer print the same day.\nconst formatDate = (iso: string) =>\n  new Date(iso).toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    timeZone: \"UTC\",\n  });\n\nfunction compare(a: Deployment, b: Deployment, key: Key) {\n  if (key === \"duration\") return a.duration - b.duration;\n  if (key === \"status\") return STATUS_RANK[a.status] - STATUS_RANK[b.status];\n  return a[key].localeCompare(b[key]);\n}\n\nexport function DataTable({\n  rows,\n  onDelete,\n  onExport,\n  className,\n}: {\n  rows: Deployment[];\n  onDelete?: (ids: string[]) => void;\n  onExport?: (ids: string[]) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [sort, setSort] = useState<Sort>(null);\n  const [selected, setSelected] = useState<Set<string>>(() => new Set());\n  const [scrolled, setScrolled] = useState(false);\n  const [exported, setExported] = useState(false);\n  // Holds the last count while the bar slides away, so it never reads \"0\".\n  const [shownCount, setShownCount] = useState(0);\n  const exportTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const allRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => () => clearTimeout(exportTimer.current), []);\n\n  // Ignores ids whose rows the parent has since removed.\n  const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);\n  const count = ids.length;\n  const all = count > 0 && count === rows.length;\n  const some = count > 0 && !all;\n  const open = count > 0;\n\n  if (open && count !== shownCount) setShownCount(count);\n\n  // indeterminate only exists as a DOM property, never as an attribute.\n  useEffect(() => {\n    if (allRef.current) allRef.current.indeterminate = some;\n  }, [some]);\n\n  const sorted = sort\n    ? [...rows].sort(\n        (a, b) => compare(a, b, sort.key) * (sort.dir === \"asc\" ? 1 : -1),\n      )\n    : rows;\n\n  // Ascending, descending, then back to the original order.\n  const cycle = (key: Key) =>\n    setSort((s) =>\n      s?.key !== key\n        ? { key, dir: \"asc\" }\n        : s.dir === \"asc\"\n          ? { key, dir: \"desc\" }\n          : null,\n    );\n\n  const toggle = (id: string) =>\n    setSelected((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n\n  const headerEdge = scrolled\n    ? \"shadow-[0_1px_0_var(--border)]\"\n    : \"shadow-[0_1px_0_transparent]\";\n\n  return (\n    <div\n      className={cn(\n        \"relative w-[600px] max-w-full overflow-hidden rounded-2xl bg-background shadow-raised\",\n        className,\n      )}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && open) setSelected(new Set());\n      }}\n    >\n      <motion.div\n        // Lets layout animations account for this box's scroll offset.\n        layoutScroll\n        onScroll={(e) => setScrolled(e.currentTarget.scrollTop > 0)}\n        // The 44px header plus six 44px rows, so two rows always sit below\n        // the fold and the sticky header has something to cover.\n        className=\"h-[308px] overflow-auto overscroll-contain\"\n      >\n        <table className=\"w-full min-w-[560px] border-separate border-spacing-0 text-sm\">\n          <thead>\n            <tr>\n              <th\n                scope=\"col\"\n                className={cn(\n                  \"sticky top-0 z-10 w-12 bg-background pl-4 text-left transition-[box-shadow] duration-150 ease-out\",\n                  headerEdge,\n                )}\n              >\n                <Checkbox\n                  ref={allRef}\n                  checked={all}\n                  disabled={rows.length === 0}\n                  onChange={() =>\n                    setSelected(all ? new Set() : new Set(rows.map((r) => r.id)))\n                  }\n                  label=\"Select all rows\"\n                />\n              </th>\n              {COLUMNS.map((col) => {\n                const dir = sort?.key === col.key ? sort.dir : null;\n                return (\n                  <th\n                    key={col.key}\n                    scope=\"col\"\n                    aria-sort={\n                      dir === \"asc\"\n                        ? \"ascending\"\n                        : dir === \"desc\"\n                          ? \"descending\"\n                          : \"none\"\n                    }\n                    className={cn(\n                      \"sticky top-0 z-10 h-11 bg-background px-1 font-normal transition-[box-shadow] duration-150 ease-out last:pr-2\",\n                      headerEdge,\n                      col.numeric ? \"text-right\" : \"text-left\",\n                    )}\n                  >\n                    <button\n                      type=\"button\"\n                      onClick={() => cycle(col.key)}\n                      className={cn(\n                        \"inline-flex h-8 items-center gap-1 rounded-lg px-2 text-[13px] font-medium whitespace-nowrap outline-hidden select-none\",\n                        \"transition-[scale,color,background-color] duration-150 ease-out hover:bg-foreground/[0.05] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n                        dir ? \"text-foreground\" : \"text-muted-foreground\",\n                        // Keeps the arrow on the outside edge, so numeric\n                        // labels stay aligned with their right-aligned values.\n                        col.numeric && \"flex-row-reverse\",\n                      )}\n                    >\n                      {col.label}\n                      <SortArrow dir={dir} />\n                    </button>\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            <AnimatePresence initial={false}>\n              {sorted.map((row) => {\n                const isSelected = selected.has(row.id);\n                return (\n                  <motion.tr\n                    key={row.id}\n                    layout={reduceMotion ? false : \"position\"}\n                    transition={{ layout: REORDER }}\n                    exit={{\n                      opacity: 0,\n                      transition: { duration: 0.15, ease: EASE_OUT },\n                    }}\n                    onClick={(e) => {\n                      // The checkbox and its label handle their own clicks.\n                      if ((e.target as HTMLElement).closest(\"label\")) return;\n                      toggle(row.id);\n                    }}\n                    className={cn(\n                      \"group cursor-default transition-[background-color] duration-150 ease-out\",\n                      isSelected\n                        ? \"bg-foreground/[0.05]\"\n                        : \"hover:bg-foreground/[0.025]\",\n                    )}\n                  >\n                    <Cell className=\"w-12 pl-4\">\n                      <Checkbox\n                        checked={isSelected}\n                        onChange={() => toggle(row.id)}\n                        label={`Select ${row.name}`}\n                      />\n                    </Cell>\n                    <Cell className=\"font-medium text-foreground\">\n                      {row.name}\n                    </Cell>\n                    <Cell>\n                      <StatusLabel status={row.status} />\n                    </Cell>\n                    <Cell className=\"font-mono text-[13px] whitespace-nowrap text-muted-foreground\">\n                      {row.branch}\n                    </Cell>\n                    <Cell className=\"text-right text-muted-foreground tabular-nums\">\n                      {row.status === \"queued\" ? \"-\" : formatDuration(row.duration)}\n                    </Cell>\n                    <Cell className=\"pr-5 text-right whitespace-nowrap text-muted-foreground tabular-nums\">\n                      {formatDate(row.date)}\n                    </Cell>\n                  </motion.tr>\n                );\n              })}\n            </AnimatePresence>\n          </tbody>\n        </table>\n        {rows.length === 0 && (\n          <p className=\"flex h-[220px] items-center justify-center text-sm text-muted-foreground\">\n            No deployments\n          </p>\n        )}\n        {/* Lets the last rows scroll clear of the action bar. Always there:\n            removing it on deselect would clamp the scroll and move the row\n            under the cursor. */}\n        <div aria-hidden className=\"h-16\" />\n      </motion.div>\n\n      {/* Parked just past the card's bottom edge and clipped by it, so it\n          rises out of the table. Enters in 200ms, leaves in 150ms. */}\n      <div\n        role=\"toolbar\"\n        aria-label=\"Bulk actions\"\n        inert={!open}\n        className={cn(\n          \"absolute inset-x-0 bottom-3 mx-auto flex h-11 w-fit items-center gap-0.5 rounded-full bg-foreground py-1 pr-1 pl-4 text-sm text-background shadow-raised\",\n          \"transition-[translate,opacity] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n          open\n            ? \"translate-y-0 opacity-100 duration-200\"\n            : \"translate-y-[calc(100%+12px)] opacity-0 duration-150 motion-reduce:translate-y-0\",\n        )}\n      >\n        <span className=\"pr-2 whitespace-nowrap tabular-nums\">\n          {shownCount} selected\n        </span>\n        <span aria-hidden className=\"mr-1 h-4 w-px bg-background/20\" />\n        <BarButton\n          onClick={() => {\n            onDelete?.(ids);\n            setSelected(new Set());\n          }}\n        >\n          <svg aria-hidden {...STROKE} className=\"size-4\">\n            <path d=\"M2.75 4.25h10.5M6.25 4.25v-1.5h3.5v1.5M4 4.25l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1\" />\n          </svg>\n          Delete\n        </BarButton>\n        <BarButton\n          onClick={() => {\n            onExport?.(ids);\n            setExported(true);\n            clearTimeout(exportTimer.current);\n            // Long enough to register, short enough to export again soon.\n            exportTimer.current = setTimeout(() => setExported(false), 1500);\n          }}\n        >\n          <span aria-hidden className=\"grid size-4\">\n            <SwapIcon visible={!exported} reduceMotion={reduceMotion}>\n              <path d=\"M8 2.75v7M5 6.75l3 3 3-3M3 11.25v1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1\" />\n            </SwapIcon>\n            <SwapIcon visible={exported} reduceMotion={reduceMotion}>\n              <path d=\"m3.5 8.5 3 3 6-7\" />\n            </SwapIcon>\n          </span>\n          Export\n        </BarButton>\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {open ? `${count} selected` : \"\"}\n        {exported ? \", exported\" : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Cell({\n  className,\n  children,\n}: {\n  className?: string;\n  children: React.ReactNode;\n}) {\n  // Borders live on cells because rows can't carry them with separated\n  // borders; the first row's is hidden so the header shadow owns that edge.\n  return (\n    <td\n      className={cn(\n        \"h-11 border-t border-border px-3 group-first:border-t-transparent\",\n        className,\n      )}\n    >\n      {children}\n    </td>\n  );\n}\n\nfunction Checkbox({\n  ref,\n  checked,\n  disabled,\n  onChange,\n  label,\n}: {\n  ref?: React.Ref<HTMLInputElement>;\n  checked: boolean;\n  disabled?: boolean;\n  onChange: () => void;\n  label: string;\n}) {\n  const mark =\n    \"pointer-events-none col-start-1 row-start-1 size-3 text-background transition-[scale,opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\";\n  // Same numbers as the icon swap recipe: from 0.25 scale and 4px blur.\n  const hidden =\n    \"scale-[0.25] opacity-0 blur-[4px] motion-reduce:scale-100 motion-reduce:blur-[0px]\";\n  return (\n    // A 36px label around the 16px box keeps the target comfortable\n    // without making the column any wider.\n    <label className=\"-m-2.5 grid size-9 cursor-pointer place-items-center\">\n      <input\n        ref={ref}\n        type=\"checkbox\"\n        checked={checked}\n        disabled={disabled}\n        onChange={onChange}\n        aria-label={label}\n        className={cn(\n          \"peer col-start-1 row-start-1 size-4 cursor-pointer appearance-none rounded-[5px] bg-background shadow-[inset_0_0_0_1.5px_var(--border)] outline-hidden disabled:cursor-default disabled:opacity-50\",\n          \"transition-[background-color,box-shadow,scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-[background-color,box-shadow]\",\n          \"hover:shadow-[inset_0_0_0_1.5px_var(--muted-foreground)] checked:bg-foreground checked:shadow-none indeterminate:bg-foreground indeterminate:shadow-none\",\n          \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        )}\n      />\n      <svg\n        aria-hidden\n        {...STROKE}\n        strokeWidth={2}\n        className={cn(\n          mark,\n          hidden,\n          \"peer-checked:scale-100 peer-checked:opacity-100 peer-checked:blur-[0px]\",\n        )}\n      >\n        <path d=\"m3.5 8.5 3 3 6-7\" />\n      </svg>\n      <svg\n        aria-hidden\n        {...STROKE}\n        strokeWidth={2}\n        className={cn(\n          mark,\n          hidden,\n          \"peer-indeterminate:scale-100 peer-indeterminate:opacity-100 peer-indeterminate:blur-[0px]\",\n        )}\n      >\n        <path d=\"M4 8h8\" />\n      </svg>\n    </label>\n  );\n}\n\nfunction SortArrow({ dir }: { dir: \"asc\" | \"desc\" | null }) {\n  return (\n    <svg\n      aria-hidden\n      {...STROKE}\n      className={cn(\n        \"size-3.5 shrink-0 transition-[rotate,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n        dir === null && \"opacity-0\",\n        // Points down for descending, the way the values run.\n        dir === \"desc\" ? \"rotate-180\" : \"rotate-0\",\n      )}\n    >\n      <path d=\"M8 12.5v-9M4.5 7 8 3.5 11.5 7\" />\n    </svg>\n  );\n}\n\nfunction StatusLabel({ status }: { status: Status }) {\n  return (\n    <span className=\"inline-flex items-center gap-2 whitespace-nowrap text-foreground\">\n      <span\n        aria-hidden\n        className={cn(\n          \"size-2 rounded-full\",\n          status === \"ready\" && \"bg-foreground\",\n          status === \"error\" && \"bg-destructive\",\n          status === \"queued\" && \"shadow-[inset_0_0_0_1.5px_var(--muted-foreground)]\",\n          // A slow pulse marks the only row that is still changing.\n          status === \"building\" &&\n            \"animate-pulse bg-muted-foreground motion-reduce:animate-none\",\n        )}\n      />\n      {STATUS_LABEL[status]}\n    </span>\n  );\n}\n\nfunction BarButton({\n  onClick,\n  children,\n}: {\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className=\"flex h-9 items-center gap-1.5 rounded-full px-3 font-medium outline-hidden select-none transition-[scale,background-color] duration-150 ease-out hover:bg-background/15 focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-background active:scale-[0.96] motion-reduce:transition-[background-color]\"\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction SwapIcon({\n  visible,\n  reduceMotion,\n  children,\n}: {\n  visible: boolean;\n  reduceMotion: boolean | null;\n  children: React.ReactNode;\n}) {\n  const hidden = reduceMotion\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  return (\n    <motion.svg\n      {...STROKE}\n      className=\"col-start-1 row-start-1 size-4\"\n      initial={false}\n      animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n      transition={ICON_SWAP}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\nconst STROKE = {\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\nconst SAMPLE: Deployment[] = [\n  { id: \"1\", name: \"web\", status: \"ready\", branch: \"main\", duration: 94, date: \"2026-09-22\" },\n  { id: \"2\", name: \"api-gateway\", status: \"building\", branch: \"feat/rate-limit\", duration: 212, date: \"2026-09-23\" },\n  { id: \"3\", name: \"docs\", status: \"ready\", branch: \"main\", duration: 41, date: \"2026-09-19\" },\n  { id: \"4\", name: \"billing\", status: \"error\", branch: \"fix/invoice-tz\", duration: 157, date: \"2026-09-21\" },\n  { id: \"5\", name: \"auth\", status: \"ready\", branch: \"release/2.4\", duration: 128, date: \"2026-09-17\" },\n  { id: \"6\", name: \"search\", status: \"queued\", branch: \"chore/deps\", duration: 0, date: \"2026-09-23\" },\n  { id: \"7\", name: \"analytics\", status: \"ready\", branch: \"main\", duration: 305, date: \"2026-09-14\" },\n  { id: \"8\", name: \"mailer\", status: \"ready\", branch: \"feat/digest\", duration: 76, date: \"2026-09-20\" },\n];\n\nexport default function DataTableDemo() {\n  const [rows, setRows] = useState(SAMPLE);\n  const full = rows.length === SAMPLE.length;\n  return (\n    <div className=\"flex w-[600px] max-w-full flex-col items-end gap-2\">\n      <DataTable\n        rows={rows}\n        onDelete={(ids) =>\n          setRows((r) => r.filter((row) => !ids.includes(row.id)))\n        }\n      />\n      {/* Always takes its space, so the table never moves when it appears. */}\n      <button\n        type=\"button\"\n        onClick={() => setRows(SAMPLE)}\n        inert={full}\n        className={cn(\n          \"h-9 rounded-full px-3 text-sm text-muted-foreground outline-hidden select-none transition-[opacity,scale,color,filter] duration-150 ease-out hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[opacity,color]\",\n          full && \"opacity-0 blur-[4px] motion-reduce:blur-[0px]\",\n        )}\n      >\n        Restore deleted rows\n      </button>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/data-table"}