# Table Sortable Animated — React Table Component

> A semantic data table whose rows glide to their new positions with spring layout animation when a column header is clicked.

- Category: [Tables & Lists](https://pixelpapa.com/components/tables.md)
- Tags: table, sort, layout, spring, data
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tables/table-sortable-animated

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/table-sortable-animated.json"
```

The file is written to `components/pixelpapa/table-sortable-animated.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUp, ChevronsUpDown } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type SortableColumn<T> = {
  key: string;
  header: string;
  align?: "left" | "right";
  /** Value used for sorting. Omit to make the column unsortable. */
  sortValue?: (row: T) => string | number;
  cell: (row: T) => ReactNode;
};

type Sort = { key: string; dir: "asc" | "desc" };

type SortableTableProps<T> = {
  columns: SortableColumn<T>[];
  rows: T[];
  rowKey: (row: T) => string;
  defaultSort?: Sort;
  caption: string;
  className?: string;
};

export function SortableTable<T>({ columns, rows, rowKey, defaultSort, caption, className }: SortableTableProps<T>) {
  const reduce = useReducedMotion();
  const [sort, setSort] = useState<Sort | null>(defaultSort ?? null);
  const sortCol = columns.find((c) => c.key === sort?.key);

  const sorted = useMemo(() => {
    const get = sortCol?.sortValue;
    if (!sort || !get) return rows;
    return [...rows].sort((a, b) => {
      const va = get(a);
      const vb = get(b);
      const d = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
      return sort.dir === "asc" ? d : -d;
    });
  }, [rows, sort, sortCol]);

  // asc → desc → original order
  function cycle(key: string) {
    setSort((s) => (s?.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null));
  }

  return (
    <div className={cn("w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm", className)}>
      <table className="w-full border-separate border-spacing-0 text-sm">
        <caption className="sr-only">{caption}</caption>
        <thead>
          <tr>
            {columns.map((col) => {
              const active = sort?.key === col.key;
              return (
                <th
                  key={col.key}
                  scope="col"
                  aria-sort={!col.sortValue ? undefined : !active ? "none" : sort.dir === "asc" ? "ascending" : "descending"}
                  className={cn(
                    "border-b border-border bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground",
                    col.align === "right" ? "text-right" : "text-left",
                  )}
                >
                  {col.sortValue ? (
                    <button
                      type="button"
                      onClick={() => cycle(col.key)}
                      className={cn(
                        "group -mx-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60",
                        col.align === "right" && "flex-row-reverse",
                        active && "text-foreground",
                      )}
                    >
                      {col.header}
                      <span aria-hidden className="relative size-3.5">
                        <ChevronsUpDown
                          className={cn(
                            "absolute inset-0 size-3.5 opacity-40 transition-opacity group-hover:opacity-80",
                            active && "opacity-0 group-hover:opacity-0",
                          )}
                        />
                        <motion.span
                          initial={false}
                          animate={{
                            opacity: active ? 1 : 0,
                            scale: active ? 1 : 0.4,
                            rotate: active && sort.dir === "desc" ? 180 : 0,
                          }}
                          transition={{ type: "spring", stiffness: 500, damping: 30 }}
                          className="absolute inset-0 text-primary"
                        >
                          <ArrowUp className="size-3.5" strokeWidth={2.5} />
                        </motion.span>
                      </span>
                    </button>
                  ) : (
                    col.header
                  )}
                </th>
              );
            })}
          </tr>
        </thead>
        <tbody>
          {sorted.map((row, i) => (
            <motion.tr
              key={rowKey(row)}
              layout={reduce ? false : "position"}
              transition={{ type: "spring", stiffness: 380, damping: 36, mass: 0.9, delay: i * 0.012 }}
              className="group/row"
            >
              {columns.map((col) => (
                <td
                  key={col.key}
                  className={cn(
                    "border-b border-border bg-card px-4 py-2.5 text-card-foreground transition-colors group-last/row:border-b-0 group-hover/row:bg-muted/40",
                    col.align === "right" && "text-right tabular-nums",
                    sort?.key === col.key && "bg-primary/[0.04] group-hover/row:bg-primary/[0.07]",
                  )}
                >
                  {col.cell(row)}
                </td>
              ))}
            </motion.tr>
          ))}
        </tbody>
      </table>
      <p aria-live="polite" className="border-t border-border px-4 py-2.5 text-xs text-muted-foreground">
        {rows.length} rows ·{" "}
        {sort && sortCol
          ? `sorted by ${sortCol.header.toLowerCase()}, ${sort.dir === "asc" ? "ascending" : "descending"}`
          : "original order"}
      </p>
    </div>
  );
}

type Account = { name: string; domain: string; plan: "Scale" | "Team" | "Starter"; seats: number; mrr: number; growth: number; hue: string };

const accounts: Account[] = [
  { name: "Halden Freight", domain: "haldenfreight.co", plan: "Scale", seats: 148, mrr: 11840, growth: 12.4, hue: "bg-chart-1" },
  { name: "Mossbank Studio", domain: "mossbank.design", plan: "Team", seats: 23, mrr: 1150, growth: -3.1, hue: "bg-chart-5" },
  { name: "Quillon Labs", domain: "quillon.dev", plan: "Scale", seats: 76, mrr: 6080, growth: 28.9, hue: "bg-chart-3" },
  { name: "Ferrow & Pike", domain: "ferrowpike.com", plan: "Starter", seats: 6, mrr: 174, growth: 4.0, hue: "bg-chart-4" },
  { name: "Tessel Robotics", domain: "tessel.systems", plan: "Team", seats: 41, mrr: 2050, growth: 9.7, hue: "bg-chart-2" },
  { name: "Oakhurst Clinic", domain: "oakhurst.health", plan: "Team", seats: 34, mrr: 1700, growth: -0.8, hue: "bg-chart-1" },
];

const planRank = { Starter: 0, Team: 1, Scale: 2 };
const maxMrr = Math.max(...accounts.map((a) => a.mrr));

const columns: SortableColumn<Account>[] = [
  {
    key: "name",
    header: "Account",
    sortValue: (a) => a.name,
    cell: (a) => (
      <div className="flex items-center gap-3">
        <span className={cn("flex size-7 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold text-white", a.hue)}>
          {a.name[0]}
        </span>
        <div className="min-w-0 leading-tight">
          <p className="truncate font-medium">{a.name}</p>
          <p className="truncate text-xs text-muted-foreground">{a.domain}</p>
        </div>
      </div>
    ),
  },
  {
    key: "plan",
    header: "Plan",
    sortValue: (a) => planRank[a.plan],
    cell: (a) => (
      <span className="rounded-full border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">{a.plan}</span>
    ),
  },
  { key: "seats", header: "Seats", align: "right", sortValue: (a) => a.seats, cell: (a) => a.seats },
  {
    key: "mrr",
    header: "MRR",
    align: "right",
    sortValue: (a) => a.mrr,
    cell: (a) => (
      <div className="ml-auto w-24">
        <p className="font-medium">${a.mrr.toLocaleString("en-US")}</p>
        <div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
          <div className="h-full rounded-full bg-primary/70" style={{ width: `${(a.mrr / maxMrr) * 100}%` }} />
        </div>
      </div>
    ),
  },
  {
    key: "growth",
    header: "30-day",
    align: "right",
    sortValue: (a) => a.growth,
    cell: (a) => (
      <span className={cn("font-medium", a.growth >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>
        {a.growth >= 0 ? "+" : "−"}
        {Math.abs(a.growth).toFixed(1)}%
      </span>
    ),
  },
];

export default function TableSortableAnimatedDemo() {
  return (
    <SortableTable
      caption="Accounts by monthly recurring revenue"
      columns={columns}
      rows={accounts}
      rowKey={(a) => a.name}
      defaultSort={{ key: "mrr", dir: "desc" }}
      className="max-w-2xl"
    />
  );
}
```

## AI prompt

```text
Add the "Table Sortable Animated" component from PixelPapa UI to this project.

What it is: A semantic data table whose rows glide to their new positions with spring layout animation when a column header is clicked.
Category: Tables & Lists. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/table-sortable-animated.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/table-sortable-animated.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/table-sortable-animated" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tables & lists

- [List Drag Reorder](https://pixelpapa.com/components/tables/list-drag-reorder.md): A ranked list you reorder by dragging a grip handle (or Arrow keys); the lifted row floats with a shadow while the others spring out of its way.
- [Table Expandable Rows](https://pixelpapa.com/components/tables/table-expandable-rows.md) (Pro): Table rows that open a detail panel underneath with a spring height animation and a rotating chevron, one at a time or many.
- [Table Inline Status Edit](https://pixelpapa.com/components/tables/table-inline-status-edit.md): Each row carries a status pill that opens a keyboard-friendly dropdown in place; the pill swaps with a spring and the row confirms the save.
- [Table Row Select Bulk](https://pixelpapa.com/components/tables/table-row-select-bulk.md) (Pro): Checkbox rows with shift-click range selection and a floating action bar that springs up from the bottom with a rolling count.
