FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Table

Table

pnpm dlx shadcn@latest add …/r/table.json

An accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.

Recent invoices
InvoiceStatusMethod
INV-1048PaidCardOct 12, 2026$1,250.00
INV-1047PendingBank transferOct 9, 2026$480.00
INV-1046OverdueCardOct 3, 2026$2,900.00
INV-1045PaidPayPalSep 28, 2026$760.50
INV-1044PaidBank transferSep 21, 2026$3,400.00
INV-1043PendingCardSep 15, 2026$150.00
Total$8,940.50
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

export function Invoices() {
  return (
    <Table>
      <TableCaption>Recent invoices</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell>INV-1048</TableCell>
          <TableCell className="text-right">$1,250.00</TableCell>
        </TableRow>
      </TableBody>
    </Table>
  )
}
invoices.tsx
"use client";

import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
  type TableSortDirection,
} from "@/components/ui/table";
import { useState } from "react";

type Status = "paid" | "pending" | "overdue";

const invoices = [
  { id: "INV-1048", status: "paid", method: "Card", date: "2026-10-12", day: "Oct 12, 2026", amount: 1250 },
  {
    id: "INV-1047",
    status: "pending",
    method: "Bank transfer",
    date: "2026-10-09",
    day: "Oct 9, 2026",
    amount: 480,
  },
  { id: "INV-1046", status: "overdue", method: "Card", date: "2026-10-03", day: "Oct 3, 2026", amount: 2900 },
  {
    id: "INV-1045",
    status: "paid",
    method: "PayPal",
    date: "2026-09-28",
    day: "Sep 28, 2026",
    amount: 760.5,
  },
  {
    id: "INV-1044",
    status: "paid",
    method: "Bank transfer",
    date: "2026-09-21",
    day: "Sep 21, 2026",
    amount: 3400,
  },
  { id: "INV-1043", status: "pending", method: "Card", date: "2026-09-15", day: "Sep 15, 2026", amount: 150 },
] satisfies { id: string; status: Status; method: string; date: string; day: string; amount: number }[];

const statuses = {
  paid: { label: "Paid", variant: "secondary" },
  pending: { label: "Pending", variant: "outline" },
  overdue: { label: "Overdue", variant: "destructive" },
} as const;

const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const total = invoices.reduce((sum, invoice) => sum + invoice.amount, 0);

type Column = "date" | "amount";

/*
 * Invoices you can sort by date or amount (the rows glide to their new places) and pick with the checkboxes.
 * It sits on the page's own colour, as a table would, so the hover and the selected rows read as they do there.
 */
export default function TableInvoices() {
  const [sort, setSort] = useState<{ column: Column; direction: Exclude<TableSortDirection, "none"> }>({
    column: "date",
    direction: "descending",
  });
  const [selected, setSelected] = useState<Set<string>>(() => new Set(["INV-1046"]));

  const rows = [...invoices].sort((a, b) => {
    const order = sort.column === "date" ? a.date.localeCompare(b.date) : a.amount - b.amount;
    return sort.direction === "ascending" ? order : -order;
  });
  const directionOf = (column: Column) => (sort.column === column ? sort.direction : "none");
  // A column sorts ascending first; pressing the sorted column again flips it
  const sortBy = (column: Column) =>
    setSort((current) =>
      current.column === column
        ? { column, direction: current.direction === "ascending" ? "descending" : "ascending" }
        : { column, direction: "ascending" },
    );

  const all = selected.size === invoices.length ? true : selected.size > 0 ? "indeterminate" : false;
  const toggle = (id: string, on: boolean) =>
    setSelected((current) => {
      const next = new Set(current);
      if (on) next.add(id);
      else next.delete(id);
      return next;
    });

  return (
    <div className="w-full max-w-2xl rounded-lg bg-background px-2 pt-1 pb-3">
      <Table>
        <TableCaption>Recent invoices</TableCaption>
        <TableHeader>
          <TableRow>
            <TableHead>
              <Checkbox
                size="sm"
                aria-label="Select all invoices"
                checked={all}
                onCheckedChange={(on) =>
                  setSelected(on === true ? new Set(invoices.map((invoice) => invoice.id)) : new Set())
                }
              />
            </TableHead>
            <TableHead>Invoice</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Method</TableHead>
            <TableHead sort={directionOf("date")} onSort={() => sortBy("date")}>
              Date
            </TableHead>
            <TableHead className="text-right" sort={directionOf("amount")} onSort={() => sortBy("amount")}>
              Amount
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((invoice) => {
            const on = selected.has(invoice.id);
            const status = statuses[invoice.status];
            return (
              <TableRow key={invoice.id} data-state={on ? "selected" : undefined}>
                <TableCell>
                  <Checkbox
                    size="sm"
                    aria-label={`Select ${invoice.id}`}
                    checked={on}
                    onCheckedChange={(value) => toggle(invoice.id, value === true)}
                  />
                </TableCell>
                <TableCell className="font-medium">{invoice.id}</TableCell>
                <TableCell>
                  <Badge variant={status.variant}>{status.label}</Badge>
                </TableCell>
                <TableCell className="text-muted-foreground">{invoice.method}</TableCell>
                <TableCell className="text-muted-foreground">{invoice.day}</TableCell>
                <TableCell className="text-right tabular-nums">{money.format(invoice.amount)}</TableCell>
              </TableRow>
            );
          })}
        </TableBody>
        <TableFooter>
          <TableRow>
            <TableCell colSpan={5}>Total</TableCell>
            <TableCell className="text-right tabular-nums">{money.format(total)}</TableCell>
          </TableRow>
        </TableFooter>
      </Table>
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

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

    Update the import paths to match your project.

API reference

Table

The table, inside a container that scrolls sideways when the columns don't fit. Also accepts every native table prop. Name it with a TableCaption or aria-label.

PropTypeDefaultDescription

TableHeader

The header rows (thead). Its rows don't take the hover.

PropTypeDefaultDescription

TableBody

The body (tbody). When its rows change order, for example on a sort, they glide to their new places; give each row a stable key. Instant with reduced motion.

PropTypeDefaultDescription

TableFooter

The footer rows (tfoot), for totals. A line above it, in medium weight.

PropTypeDefaultDescription

TableRow

A row (tr). Also accepts every native tr prop.

PropTypeDefaultDescription
data-state?"selected"–Marks the row as selected: it takes a muted background.

TableHead

A header cell (th). Also accepts every native th prop. Add className="text-right" for a column of numbers; a sort arrow then goes before the label.

PropTypeDefaultDescription
sort?"ascending" | "descending" | "none"–The column's sort. The sorted column gets aria-sort; the arrow points up for ascending and turns down for descending.
onSort?() => void–Makes the label a button that sorts the column. Called when it's pressed; update sort in response. Unsorted columns show their arrow on hover.

TableCell

A data cell (td). Also accepts every native td prop, such as colSpan.

PropTypeDefaultDescription

TableCaption

The table's title, shown below it and read as its name.

PropTypeDefaultDescription

TableSortButton

The button TableHead renders for a sortable column. Use it directly only for a header of your own, and set aria-sort on that header yourself. Also accepts every native button prop.

PropTypeDefaultDescription
direction?"ascending" | "descending" | "none""none"Which way the arrow points; hidden until hover when none.

Related components

    • More pages
    Pagination

    An accessible React pagination for Next.js and Tailwind CSS: page links with ellipses, Previous and Next, and a pill that slides to the current page.

  • Checkbox

    An accessible React checkbox for Next.js forms, with a check that draws itself in and an indeterminate state, built on Radix Checkbox.

  • Stablev2.4.0Beta
    Badge

    An accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.