SaaS & Ops

Database Table

An Airtable-style grid with typed cells — text, colored select tags, date, number and checkbox — inline editing and an add-record footer.

Install

npx shadcn@latest add @paragon/database-table

Also installs: dropdown-menu

database-table.tsx

"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",
      )}
    />
  );
}