{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table",
  "type": "registry:ui",
  "title": "Table",
  "description": "An accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "lucide-react@^1.52.0",
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/table.tsx",
      "type": "registry:ui",
      "content": "import {\n  TableBody,\n  TableSortButton,\n  type TableSortDirection,\n} from \"@/components/ui/table-motion\";\nimport { cn } from \"@/lib/utils\";\nimport type { ComponentProps } from \"react\";\n\n/*\n * Table (owner's roadmap, 2026-10-16: \"Data in rows and columns, with sortable headers\"), with shadcn's parts:\n * Table (inside a container that scrolls sideways), TableHeader, TableBody, TableFooter, TableRow, TableHead,\n * TableCell and TableCaption. A real HTML table, so screen readers announce rows, columns and headers.\n * - Borders: quiet dividers between rows and none round the outside (design.md: few borders). The lines belong\n *   to the cells, with the table's borders kept separate and no spacing between them, so each row carries its\n *   own line when it glides.\n * - Sorting: give TableHead `sort` and `onSort`. Its label becomes a button, the header gets aria-sort (only the\n *   sorted column has one, as in the WAI-ARIA sortable table), and the arrow turns between up and down.\n * - When sorting changes the rows' order, they glide to their new places (TableBody, in table-motion.tsx with\n *   the sort button: the two client parts). Give rows a stable `key` so they can.\n * - Rows: hover is a colour change only, and a selected row (data-state=\"selected\") is muted. A checkbox column\n *   stays as narrow as its checkbox.\n */\n\n/** Wraps the table in a container that scrolls sideways when the columns don't fit */\nfunction Table({ className, ...props }: ComponentProps<\"table\">) {\n  return (\n    <div data-slot=\"table-container\" className=\"relative w-full overflow-x-auto\">\n      <table\n        data-slot=\"table\"\n        className={cn(\"w-full caption-bottom border-separate border-spacing-0 text-sm\", className)}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction TableHeader({ className, ...props }: ComponentProps<\"thead\">) {\n  return (\n    <thead\n      data-slot=\"table-header\"\n      // Header rows don't take the body's hover\n      className={cn(\"[&_tr]:hover:bg-transparent\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction TableFooter({ className, ...props }: ComponentProps<\"tfoot\">) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      // One line between the body and the footer (the body's last row has none), and none under it\n      className={cn(\n        \"font-medium [&_tr]:hover:bg-transparent [&>tr:first-child>*]:border-t [&>tr:first-child>*]:border-border [&>tr:last-child>*]:border-b-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableRow({ className, ...props }: ComponentProps<\"tr\">) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        \"transition-colors duration-(--duration-fast) ease-out hover:bg-muted/50 data-[state=selected]:bg-muted\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type TableHeadProps = ComponentProps<\"th\"> & {\n  /** This column's sort. Sets aria-sort on the sorted column and turns the arrow. */\n  sort?: TableSortDirection;\n  /** Makes the header a button that sorts the column; called when it's pressed */\n  onSort?: () => void;\n};\n\nfunction TableHead({ className, sort, onSort, children, ...props }: TableHeadProps) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      aria-sort={sort === \"ascending\" || sort === \"descending\" ? sort : undefined}\n      className={cn(\n        \"h-10 border-b border-border px-3 text-start align-middle font-medium whitespace-nowrap text-muted-foreground\",\n        \"[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:flex\",\n        className,\n      )}\n      {...props}\n    >\n      {onSort ? (\n        <TableSortButton direction={sort ?? \"none\"} onClick={onSort}>\n          {children}\n        </TableSortButton>\n      ) : (\n        children\n      )}\n    </th>\n  );\n}\n\nfunction TableCell({ className, ...props }: ComponentProps<\"td\">) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        \"border-b border-border px-3 py-2.5 align-middle whitespace-nowrap\",\n        \"[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:flex\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TableCaption({ className, ...props }: ComponentProps<\"caption\">) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type { TableSortButtonProps, TableSortDirection } from \"@/components/ui/table-motion\";\nexport {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableFooter,\n  TableHead,\n  TableHeader,\n  TableRow,\n  TableSortButton,\n};\n"
    },
    {
      "path": "registry/fewtone/ui/table-motion.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { duration, spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { ArrowUpIcon } from \"lucide-react\";\nimport {\n  type AnimationPlaybackControls,\n  animate,\n  type MotionValue,\n  motion,\n  motionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ComponentProps, useEffect, useRef } from \"react\";\n\n/*\n * The client parts of Table: the body, whose rows glide when their order changes, and the sort button with its\n * arrow. table.tsx re-exports them, so every other part stays a server component.\n * - Rows glide (FLIP, .agents/rules/motion.md): when sorting reorders the rows, each row starts from where it\n *   was drawn and glides to its new place on spring.snappy. Only a transform moves, and rows stay real `tr`s.\n *   Places are offsets (layout pixels), so a zoomed or scaled page doesn't double the trip. Re-sorting mid-glide\n *   leaves from where each row is now. Rows that are added, removed or resized just take their places: only a\n *   new order glides. With reduced motion nothing moves.\n * - The sort arrow turns between up and down on spring.snappy. Unsorted, it's hidden until you point at the\n *   header (or focus it); when a column stops being sorted it fades out where it was, then resets.\n */\n\nexport type TableSortDirection = \"ascending\" | \"descending\" | \"none\";\n\n/** A row on its way to its new place: its offset, and the spring taking it there */\ntype Glide = { y: MotionValue<number>; animation?: AnimationPlaybackControls; stop: () => void };\n\n/** Where a row sits in its body, in layout pixels (offsets: transforms and zoom don't count) */\nconst placeOf = (row: HTMLTableRowElement, body: HTMLTableSectionElement) => row.offsetTop - body.offsetTop;\n\nfunction TableBody({ className, ref, ...props }: ComponentProps<\"tbody\">) {\n  const node = useRef<HTMLTableSectionElement | null>(null);\n  const reduce = useReducedMotion() ?? false;\n  // Read by the observer below, which outlives renders\n  const reduceRef = useRef(reduce);\n  useEffect(() => {\n    reduceRef.current = reduce;\n  }, [reduce]);\n\n  useEffect(() => {\n    const body = node.current;\n    if (!body) return;\n    const glides = new Map<HTMLTableRowElement, Glide>();\n    let order: HTMLTableRowElement[] = [];\n    let places = new Map<HTMLTableRowElement, number>();\n\n    /** Note each row's place, to glide from next time. Rows that left the table let go of their glide. */\n    const remember = () => {\n      order = Array.from(body.rows);\n      places = new Map(order.map((row) => [row, placeOf(row, body)]));\n      for (const [row, glide] of glides) if (!places.has(row)) glide.stop();\n    };\n\n    // React moves the rows' nodes when their keys change order. The observer runs after it does and before the\n    // browser paints, so each row can be put back where it was drawn, then let go.\n    const onRowsChange = () => {\n      const before = order;\n      const was = places;\n      remember();\n      if (reduceRef.current) return;\n      const kept = order.filter((row) => was.has(row));\n      const keptBefore = before.filter((row) => places.has(row));\n      if (kept.every((row, index) => row === keptBefore[index])) return;\n\n      for (const row of kept) {\n        let glide = glides.get(row);\n        const from = (was.get(row) ?? 0) - (places.get(row) ?? 0) + (glide?.y.get() ?? 0);\n        // Still and already in place: nothing to do. A row mid-glide is placed again either way.\n        if (from === 0 && !glide) continue;\n        if (!glide) {\n          const y = motionValue(0);\n          const unsubscribe = y.on(\"change\", (value) => {\n            row.style.transform = value === 0 ? \"\" : `translateY(${value}px)`;\n          });\n          const created: Glide = {\n            y,\n            stop: () => {\n              created.animation?.stop();\n              unsubscribe();\n              row.style.transform = \"\";\n              glides.delete(row);\n            },\n          };\n          glide = created;\n          glides.set(row, glide);\n        }\n        // Placed with jump(), which resets the speed, so the spring doesn't count the jump as a push\n        glide.animation?.stop();\n        glide.y.jump(from);\n        glide.animation = animate(glide.y, 0, spring.snappy);\n      }\n    };\n\n    remember();\n    const rows = new MutationObserver(onRowsChange);\n    rows.observe(body, { childList: true });\n    // Places change when the table reflows (a new width, rows that grow), so note them again\n    const size = new ResizeObserver(remember);\n    size.observe(body);\n    return () => {\n      rows.disconnect();\n      size.disconnect();\n      for (const glide of glides.values()) glide.stop();\n    };\n  }, []);\n\n  return (\n    <tbody\n      ref={(element) => {\n        node.current = element;\n        if (typeof ref === \"function\") ref(element);\n        else if (ref) ref.current = element;\n      }}\n      data-slot=\"table-body\"\n      className={cn(\"[&>tr:last-child>*]:border-b-0\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type TableSortButtonProps = ComponentProps<\"button\"> & {\n  /** The column's sort, which turns the arrow */\n  direction?: TableSortDirection;\n};\n\n/**\n * The button inside a sortable TableHead. TableHead renders it for you when you give it `onSort`; use it directly\n * only to build a header of your own (set aria-sort on the header yourself then).\n */\nfunction TableSortButton({ direction = \"none\", className, children, ...props }: TableSortButtonProps) {\n  const reduce = useReducedMotion() ?? false;\n  const sorted = direction !== \"none\";\n\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"table-sort-button\"\n      data-sort={direction}\n      className={cn(\n        \"group/sort -mx-1.5 inline-flex h-(--size-control-sm) cursor-pointer items-center gap-1 rounded-md px-1.5 font-medium whitespace-nowrap\",\n        \"transition-colors duration-(--duration-fast) ease-out hover:text-foreground\",\n        sorted && \"text-foreground\",\n        // In a right-aligned column (numbers), the arrow goes first, so the label lines up with the values\n        \"in-[.text-right]:flex-row-reverse in-[.text-end]:flex-row-reverse\",\n        \"outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      {/* The arrow always keeps its room, so the label never shifts as it appears */}\n      <span\n        aria-hidden\n        className={cn(\n          \"inline-flex transition-opacity duration-(--duration-fast) ease-out\",\n          sorted\n            ? \"opacity-100\"\n            : \"text-muted-foreground opacity-0 group-hover/sort:opacity-100 group-focus-visible/sort:opacity-100\",\n        )}\n      >\n        <motion.span\n          className=\"inline-flex\"\n          initial={false}\n          animate={{ rotate: direction === \"descending\" ? 180 : 0 }}\n          // Turning, it springs. Leaving a column, it fades out pointing where it pointed, then resets.\n          transition={\n            reduce ? { duration: 0 } : sorted ? spring.snappy : { duration: 0, delay: duration.fast }\n          }\n        >\n          <ArrowUpIcon className=\"size-(--size-icon-sm)\" />\n        </motion.span>\n      </span>\n    </button>\n  );\n}\n\nexport { TableBody, TableSortButton };\n"
    }
  ],
  "categories": [
    "display"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/table"
}
