Table
A plain, composable data table: sortable headers with an arrow that only shows on hover until active, and rows that tint when selected.
| Role | Status | Score | ||
|---|---|---|---|---|
| Aiko Sato | Engineering | Away | Calculating… | |
| Callum Reed | Product | Active | Calculating… | |
| Priya Nair | Design | Active | Calculating… | |
| Theo Marsh | Engineering | Active | Calculating… |
"use client";
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/motion/badge";
import { Checkbox } from "@/components/motion/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/motion/table";
type Person = { id: string; name: string; role: string; status: "Active" | "Away" };
const PEOPLE: Person[] = [
{ id: "1", name: "Priya Nair", role: "Design", status: "Active" },
{ id: "2", name: "Theo Marsh", role: "Engineering", status: "Active" },
{ id: "3", name: "Aiko Sato", role: "Engineering", status: "Away" },
{ id: "4", name: "Callum Reed", role: "Product", status: "Active" },
];
/** Fake load scores as if they'd just been computed, to demo the loading cell. */
const SCORES: Record<string, number> = { "1": 92, "2": 88, "3": 95, "4": 81 };
export function TablePreview() {
const [sortAsc, setSortAsc] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [nameWidth, setNameWidth] = useState<number>();
const [scoresReady, setScoresReady] = useState(false);
useEffect(() => {
const timeout = window.setTimeout(() => setScoresReady(true), 1200);
return () => window.clearTimeout(timeout);
}, []);
const rows = useMemo(
() => [...PEOPLE].sort((a, b) => (sortAsc ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name))),
[sortAsc],
);
const allSelected = selected.size === PEOPLE.length;
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(PEOPLE.map((p) => p.id)));
const toggleOne = (id: string) =>
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div className="w-full max-w-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />
</TableHead>
<TableHead
sorted={sortAsc ? "asc" : "desc"}
onSort={() => setSortAsc((value) => !value)}
onResize={setNameWidth}
style={nameWidth ? { width: nameWidth } : undefined}
>
Name
</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((person) => (
<TableRow key={person.id} selected={selected.has(person.id)}>
<TableCell className="w-10">
<Checkbox
checked={selected.has(person.id)}
onCheckedChange={() => toggleOne(person.id)}
aria-label={`Select ${person.name}`}
/>
</TableCell>
<TableCell emphasis style={nameWidth ? { width: nameWidth } : undefined}>
{person.name}
</TableCell>
<TableCell>
<Badge variant="neutral">{person.role}</Badge>
</TableCell>
<TableCell>
<Badge variant={person.status === "Active" ? "success" : "warning"}>{person.status}</Badge>
</TableCell>
<TableCell loading={!scoresReady}>{scoresReady ? SCORES[person.id] : null}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// easeui.dev/components/motion/table
import { ArrowUp } from "lucide-react";
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
/** Smallest width a dragged column can shrink to. */
const MIN_COLUMN_WIDTH = 80;
export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="w-full overflow-x-auto rounded-2xl shadow-[0_0_0_1px_var(--border)]">
<table className={cn("w-full border-collapse text-sm", className)} {...props} />
</div>
);
}
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("bg-muted/50", className)} {...props} />;
}
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("divide-y divide-border", className)} {...props} />;
}
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
/** Highlights the row as selected. Default false. */
selected?: boolean;
}
export function TableRow({ selected = false, className, ...props }: TableRowProps) {
return (
<tr
data-selected={selected}
className={cn(
"transition-colors duration-150 hover:bg-muted/40 data-[selected=true]:bg-accent/5",
className,
)}
{...props}
/>
);
}
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
/** Shows a sort arrow and makes the header clickable. Omit for a plain, unsortable column. */
sorted?: "asc" | "desc" | false;
onSort?: () => void;
/** Shows a drag handle on the trailing edge; reports the column's new width in pixels as the pointer moves. Width itself stays the caller's state, same as sort. */
onResize?: (width: number) => void;
}
/** A thin strip on a header's trailing edge that drags the column wider or narrower. */
function ResizeHandle({ onResize }: { onResize: (width: number) => void }) {
return (
<span
aria-hidden="true"
onPointerDown={(event) => {
event.preventDefault();
const th = event.currentTarget.closest("th");
if (!th) return;
const startX = event.clientX;
const startWidth = th.getBoundingClientRect().width;
const previousCursor = document.body.style.cursor;
const previousSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const move = (moveEvent: PointerEvent) => {
onResize(Math.max(MIN_COLUMN_WIDTH, startWidth + moveEvent.clientX - startX));
};
const finish = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousSelect;
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
}}
className="absolute inset-y-2 right-0 w-1 cursor-col-resize touch-none rounded-full bg-foreground/15 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
/>
);
}
/** A column heading. Pass onSort and it becomes a button with a sort arrow that only shows on hover until active. Pass onResize to add a drag handle on its trailing edge. */
export function TableHead({ sorted = false, onSort, onResize, className, children, ...props }: TableHeadProps) {
if (!onSort) {
return (
<th
className={cn(
"group relative h-10 px-3 text-left text-xs font-medium text-muted-foreground",
className,
)}
{...props}
>
{children}
{onResize ? <ResizeHandle onResize={onResize} /> : null}
</th>
);
}
return (
<th
className={cn("group relative h-10 px-1 text-left text-xs font-medium text-muted-foreground", className)}
{...props}
>
<button
type="button"
onClick={onSort}
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-md px-2 outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/40"
>
{children}
<ArrowUp
aria-hidden="true"
className={cn(
"h-3 w-3 transition-[opacity,transform] duration-150",
sorted ? "opacity-100" : "opacity-0 group-hover:opacity-40",
sorted === "desc" && "rotate-180",
)}
/>
</button>
{onResize ? <ResizeHandle onResize={onResize} /> : null}
</th>
);
}
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
/** Bolds the cell in the foreground color, for a row's primary column, such as a name. Default false. */
emphasis?: boolean;
/** Shows a muted "Calculating…" placeholder in place of children, for a value still being computed. Default false. */
loading?: boolean;
}
export function TableCell({ emphasis = false, loading = false, className, children, ...props }: TableCellProps) {
return (
<td
className={cn("px-3 py-2.5 align-middle", emphasis && "font-medium text-foreground", className)}
{...props}
>
{loading ? (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
Calculating…
<span
aria-hidden="true"
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground motion-reduce:animate-none"
/>
</span>
) : (
children
)}
</td>
);
}
Installation
$ bunx --bun shadcn add @easeui/table
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react tailwind-merge - 3
Add the source files
components/motion/table.tsx // easeui.dev/components/motion/table import { ArrowUp } from "lucide-react"; import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react"; import { cn } from "@/lib/utils"; /** Smallest width a dragged column can shrink to. */ const MIN_COLUMN_WIDTH = 80; export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) { return ( <div className="w-full overflow-x-auto rounded-2xl shadow-[0_0_0_1px_var(--border)]"> <table className={cn("w-full border-collapse text-sm", className)} {...props} /> </div> ); } export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) { return <thead className={cn("bg-muted/50", className)} {...props} />; } export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) { return <tbody className={cn("divide-y divide-border", className)} {...props} />; } export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> { /** Highlights the row as selected. Default false. */ selected?: boolean; } export function TableRow({ selected = false, className, ...props }: TableRowProps) { return ( <tr data-selected={selected} className={cn( "transition-colors duration-150 hover:bg-muted/40 data-[selected=true]:bg-accent/5", className, )} {...props} /> ); } export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> { /** Shows a sort arrow and makes the header clickable. Omit for a plain, unsortable column. */ sorted?: "asc" | "desc" | false; onSort?: () => void; /** Shows a drag handle on the trailing edge; reports the column's new width in pixels as the pointer moves. Width itself stays the caller's state, same as sort. */ onResize?: (width: number) => void; } /** A thin strip on a header's trailing edge that drags the column wider or narrower. */ function ResizeHandle({ onResize }: { onResize: (width: number) => void }) { return ( <span aria-hidden="true" onPointerDown={(event) => { event.preventDefault(); const th = event.currentTarget.closest("th"); if (!th) return; const startX = event.clientX; const startWidth = th.getBoundingClientRect().width; const previousCursor = document.body.style.cursor; const previousSelect = document.body.style.userSelect; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; const move = (moveEvent: PointerEvent) => { onResize(Math.max(MIN_COLUMN_WIDTH, startWidth + moveEvent.clientX - startX)); }; const finish = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", finish); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousSelect; }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", finish); }} className="absolute inset-y-2 right-0 w-1 cursor-col-resize touch-none rounded-full bg-foreground/15 opacity-0 transition-opacity duration-150 group-hover:opacity-100" /> ); } /** A column heading. Pass onSort and it becomes a button with a sort arrow that only shows on hover until active. Pass onResize to add a drag handle on its trailing edge. */ export function TableHead({ sorted = false, onSort, onResize, className, children, ...props }: TableHeadProps) { if (!onSort) { return ( <th className={cn( "group relative h-10 px-3 text-left text-xs font-medium text-muted-foreground", className, )} {...props} > {children} {onResize ? <ResizeHandle onResize={onResize} /> : null} </th> ); } return ( <th className={cn("group relative h-10 px-1 text-left text-xs font-medium text-muted-foreground", className)} {...props} > <button type="button" onClick={onSort} className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-md px-2 outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/40" > {children} <ArrowUp aria-hidden="true" className={cn( "h-3 w-3 transition-[opacity,transform] duration-150", sorted ? "opacity-100" : "opacity-0 group-hover:opacity-40", sorted === "desc" && "rotate-180", )} /> </button> {onResize ? <ResizeHandle onResize={onResize} /> : null} </th> ); } export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> { /** Bolds the cell in the foreground color, for a row's primary column, such as a name. Default false. */ emphasis?: boolean; /** Shows a muted "Calculating…" placeholder in place of children, for a value still being computed. Default false. */ loading?: boolean; } export function TableCell({ emphasis = false, loading = false, className, children, ...props }: TableCellProps) { return ( <td className={cn("px-3 py-2.5 align-middle", emphasis && "font-medium text-foreground", className)} {...props} > {loading ? ( <span className="inline-flex items-center gap-1.5 text-muted-foreground"> Calculating… <span aria-hidden="true" className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground motion-reduce:animate-none" /> </span> ) : ( children )} </td> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/badge.tsx import type { HTMLAttributes } from "react"; import { cn } from "@/lib/utils"; export type BadgeVariant = "neutral" | "accent" | "success" | "warning" | "destructive"; export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> { /** Color treatment. Default "neutral". */ variant?: BadgeVariant; } const VARIANT_CLASS: Record<BadgeVariant, string> = { neutral: "bg-muted text-muted-foreground shadow-[0_0_0_1px_var(--border)]", accent: "bg-accent/15 text-accent", success: "bg-success/15 text-success", warning: "bg-warning/15 text-warning", destructive: "bg-destructive/15 text-destructive", }; /** A small status pill. Crossfades color when its variant changes, such as pending to success. */ export function Badge({ variant = "neutral", className, ...props }: BadgeProps) { return ( <span className={cn( "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-150 ease-out", VARIANT_CLASS[variant], className, )} {...props} /> ); }components/motion/checkbox.tsx "use client"; import { Check } from "lucide-react"; import { forwardRef, type InputHTMLAttributes, useState } from "react"; import { cn } from "@/lib/utils"; export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> { /** Controlled checked state. */ checked?: boolean; /** Starting state when uncontrolled. Default false. */ defaultChecked?: boolean; /** Called with the next state each time the checkbox is toggled. */ onCheckedChange?: (checked: boolean) => void; } /** * A checkbox on a real checkbox input, with a check mark that pops in rather * than just appearing. Put it inside a label and the whole row becomes the * tap target, with no dead space between the text and the box. */ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox( { checked, defaultChecked = false, onCheckedChange, className, onChange, ...props }, ref, ) { const [uncontrolled, setUncontrolled] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : uncontrolled; return ( <span className={cn("relative inline-flex h-5 w-5 shrink-0 items-center justify-center", className)}> <input ref={ref} type="checkbox" checked={on} onChange={(event) => { onChange?.(event); if (!isControlled) setUncontrolled(event.target.checked); onCheckedChange?.(event.target.checked); }} className={cn( "peer absolute inset-0 m-0 h-5 w-5 shrink-0 touch-manipulation cursor-pointer appearance-none rounded-md outline-none", "shadow-[0_0_0_1px_var(--border-strong)] transition-colors duration-150 ease-out", "checked:bg-accent checked:shadow-[0_0_0_1px_var(--accent)]", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:cursor-not-allowed disabled:opacity-50", // Grows the hit area to about 44px without changing the layout. "after:absolute after:-inset-3", )} {...props} /> <Check aria-hidden="true" strokeWidth={3} className="pointer-events-none relative h-3.5 w-3.5 scale-50 text-accent-foreground opacity-0 transition-[opacity,scale] duration-150 ease-out peer-checked:scale-100 peer-checked:opacity-100 motion-reduce:transition-none" /> </span> ); });
API reference
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| TableRow | selected? | boolean | false | Highlights the row as selected. Default false. |
| TableHead | sorted? | false | "asc" | "desc" | false | Shows a sort arrow and makes the header clickable. Omit for a plain, unsortable column. |
| TableHead | onSort? | (() => void) | - | - |
| TableHead | onResize? | ((width: number) => void) | - | Shows a drag handle on the trailing edge; reports the column's new width in pixels as the pointer moves. Width itself stays the caller's state, same as sort. |
| TableCell | emphasis? | boolean | false | Bolds the cell in the foreground color, for a row's primary column, such as a name. Default false. |
| TableCell | loading? | boolean | false | Shows a muted "Calculating…" placeholder in place of children, for a value still being computed. Default false. |
Updated