An Airtable-style grid with typed cells — text, colored select tags, date, number and checkbox — inline editing and an add-record footer.
npx shadcn@latest add @paragon/database-tableAlso installs: dropdown-menu
"use client";
import * as React from "react";
import {
Plus,
Type,
ChevronDown,
Calendar,
CheckSquare,
Hash,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type DatabaseColumnType =
| "text"
| "select"
| "date"
| "checkbox"
| "number";
export interface SelectOption {
value: string;
/** Tailwind-ish token class or CSS color for the tag dot. */
color: string;
}
export interface DatabaseColumn {
id: string;
name: string;
type: DatabaseColumnType;
/** For `select` columns. */
options?: SelectOption[];
width?: number;
}
export type DatabaseRow = Record<string, string | number | boolean>;
export interface DatabaseTableProps extends React.ComponentProps<"div"> {
columns?: DatabaseColumn[];
rows?: DatabaseRow[];
}
const typeIcon: Record<DatabaseColumnType, React.ElementType> = {
text: Type,
select: ChevronDown,
date: Calendar,
checkbox: CheckSquare,
number: Hash,
};
const DEFAULT_COLUMNS: DatabaseColumn[] = [
{ id: "name", name: "Task", type: "text", width: 240 },
{
id: "status",
name: "Status",
type: "select",
width: 140,
options: [
{ value: "Todo", color: "oklch(0.7 0 0)" },
{ value: "In progress", color: "oklch(0.75 0.16 70)" },
{ value: "Done", color: "oklch(0.7 0.15 163)" },
],
},
{ id: "owner", name: "Owner", type: "text", width: 140 },
{ id: "due", name: "Due", type: "date", width: 120 },
{ id: "points", name: "Points", type: "number", width: 90 },
{ id: "shipped", name: "Shipped", type: "checkbox", width: 90 },
];
const DEFAULT_ROWS: DatabaseRow[] = [
{ name: "Sign webhook payloads", status: "In progress", owner: "Priya", due: "Jul 08", points: 5, shipped: false },
{ name: "Command palette nav", status: "Todo", owner: "Marcus", due: "Jul 11", points: 3, shipped: false },
{ name: "Audit log CSV export", status: "Todo", owner: "Dana", due: "Jul 14", points: 2, shipped: false },
{ name: "SSO domain verify", status: "Done", owner: "Priya", due: "Jul 02", points: 5, shipped: true },
];
/**
* An Airtable-style grid with typed cells. Text and number cells are inline
* `contentEditable`/input regions; select cells open a colored-tag dropdown;
* date cells edit through a native date affordance; checkbox cells toggle in
* place. Column headers carry a type glyph. A sticky footer row adds a blank
* record. All edits write to local state so the demo remounts clean on replay.
*/
export function DatabaseTable({
columns = DEFAULT_COLUMNS,
rows = DEFAULT_ROWS,
className,
...props
}: DatabaseTableProps) {
const [data, setData] = React.useState(rows);
const setCell = (
rowIndex: number,
columnId: string,
value: string | number | boolean,
) =>
setData((current) =>
current.map((row, i) =>
i === rowIndex ? { ...row, [columnId]: value } : row,
),
);
const addRow = () =>
setData((current) => [
...current,
Object.fromEntries(
columns.map((c) => [c.id, c.type === "checkbox" ? false : c.type === "number" ? 0 : ""]),
) as DatabaseRow,
]);
const gridTemplate = columns.map((c) => `${c.width ?? 140}px`).join(" ");
return (
<div
data-slot="database-table"
className={cn(
"w-full overflow-x-auto rounded-xl bg-card text-card-foreground shadow-border [scrollbar-width:thin]",
className,
)}
{...props}
>
<div style={{ minWidth: "fit-content" }}>
{/* Header. */}
<div
className="grid border-b border-border bg-secondary/40"
style={{ gridTemplateColumns: gridTemplate }}
>
{columns.map((column) => {
const Icon = typeIcon[column.type];
return (
<div
key={column.id}
className="flex items-center gap-1.5 border-r border-border px-2.5 py-2 text-xs font-medium text-muted-foreground last:border-r-0"
>
<Icon className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{column.name}</span>
</div>
);
})}
</div>
{/* Rows. */}
<div className="divide-y divide-border">
{data.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid transition-colors duration-(--duration-fast) ease-out hover:bg-accent/30"
style={{ gridTemplateColumns: gridTemplate }}
>
{columns.map((column) => (
<div
key={column.id}
className="min-w-0 border-r border-border last:border-r-0"
>
<Cell
column={column}
value={row[column.id]}
onChange={(value) => setCell(rowIndex, column.id, value)}
/>
</div>
))}
</div>
))}
</div>
{/* Add row. */}
<button
type="button"
onClick={addRow}
className={cn(
"flex w-full items-center gap-1.5 border-t border-border px-2.5 py-2 text-left text-sm text-muted-foreground",
"transition-colors duration-(--duration-fast) ease-out hover:bg-accent/40 hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
>
<Plus className="size-4" aria-hidden />
New record
</button>
</div>
</div>
);
}
function Cell({
column,
value,
onChange,
}: {
column: DatabaseColumn;
value: string | number | boolean | undefined;
onChange: (value: string | number | boolean) => void;
}) {
if (column.type === "checkbox") {
const checked = value === true;
return (
<div className="flex h-full items-center justify-center px-2.5 py-1.5">
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={`${column.name} ${checked ? "checked" : "unchecked"}`}
onClick={() => onChange(!checked)}
className={cn(
"flex size-4 items-center justify-center rounded-[5px] border outline-none",
"transition-[background-color,border-color] duration-(--duration-fast) ease-out",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
checked ? "border-primary bg-primary text-primary-foreground" : "border-input",
)}
>
{checked && (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-3" aria-hidden>
<path d="M2.25 6.4 4.9 9 9.75 3.5" />
</svg>
)}
</button>
</div>
);
}
if (column.type === "select") {
const option = column.options?.find((o) => o.value === value);
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"flex h-full w-full items-center px-2.5 py-1.5 text-left text-sm outline-none",
"transition-colors duration-(--duration-fast) hover:bg-accent/40",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
>
{option ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
<span
aria-hidden
className="size-1.5 rounded-full"
style={{ backgroundColor: option.color }}
/>
{option.value}
</span>
) : (
<span className="text-muted-foreground">Empty</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
{column.options?.map((opt) => (
<DropdownMenuItem
key={opt.value}
onSelect={() => onChange(opt.value)}
className="gap-2"
>
<span
aria-hidden
className="size-1.5 rounded-full"
style={{ backgroundColor: opt.color }}
/>
{opt.value}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
if (column.type === "date") {
return (
<input
type="text"
value={String(value ?? "")}
onChange={(event) => onChange(event.target.value)}
aria-label={column.name}
placeholder="—"
className={cn(
"h-full w-full bg-transparent px-2.5 py-1.5 text-sm tabular-nums outline-none",
"placeholder:text-muted-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
/>
);
}
if (column.type === "number") {
return (
<input
type="number"
value={value === undefined || value === "" ? "" : Number(value)}
onChange={(event) =>
onChange(event.target.value === "" ? "" : Number(event.target.value))
}
aria-label={column.name}
className={cn(
"h-full w-full bg-transparent px-2.5 py-1.5 text-right text-sm tabular-nums outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none",
)}
/>
);
}
return (
<input
type="text"
value={String(value ?? "")}
onChange={(event) => onChange(event.target.value)}
aria-label={column.name}
placeholder="Empty"
className={cn(
"h-full w-full bg-transparent px-2.5 py-1.5 text-sm outline-none",
"placeholder:text-muted-foreground/60",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
/>
);
}