Westy Design Systemv0.2.7
OverviewComponentsExamplesStorybook

Foundations

  • Typography
  • Colors
  • Spacing
  • Radius
  • Shadows
  • Icons

Components

  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Data Table
  • Date Picker
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Menubar
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Questionnaire
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sonner
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toggle
  • Toggle Group
  • Tooltip

Chat

  • Attachment
  • Bubble
  • Marker
  • Message
  • Message Scroller

Components

Data Table

Powerful table and datagrids built using TanStack Table. A pattern — not a @workspace/ui component — composing Table with headless state from @tanstack/react-table.

Playground

Status
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
0 of 5 row(s) selected.
—columnFilteringFeature
—columnVisibilityFeature
—rowSelectionFeature
—rowPaginationFeature
Setup
Every data table behaves differently — sorting, filtering, and data sources vary — so instead of a monolithic component, build your own from the Table parts and TanStack Table v9. Add the dependency to the consuming app:
yarn add @tanstack/react-table
v9 is feature-based: declare the behavior you want with tableFeatures() — anything you don't register (including built-in filter and sort functions) is tree-shaken out of the bundle. Share the object across your column and table modules:
// data-table-features.ts
import {
  columnFilteringFeature,
  columnVisibilityFeature,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_text,
  tableFeatures,
} from "@tanstack/react-table"

export const features = tableFeatures({
  columnFilteringFeature,
  columnVisibilityFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  filteredRowModel: createFilteredRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  sortedRowModel: createSortedRowModel(),
  filterFns: { includesString: filterFn_includesString },
  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})

export type DataTableFeatures = typeof features
Usage
Define columns with createColumnHelper — typed by your features object and row shape — create the instance with useTable, and render it by looping header groups and rows into the Table parts. table.FlexRender renders each header and cell definition in place.
StatusEmailAmount
successken99@example.com316
successAbe45@example.com242
processingMonserrat44@example.com837
successSilas22@example.com874
failedcarmella@example.com721
"use client"

import { createColumnHelper, useTable } from "@tanstack/react-table"

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@workspace/ui/components/table"

import { features, type DataTableFeatures } from "./data-table-features"

type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}

const columnHelper = createColumnHelper<DataTableFeatures, Payment>()

const columns = columnHelper.columns([
  columnHelper.accessor("status", { header: "Status" }),
  columnHelper.accessor("email", { header: "Email" }),
  columnHelper.accessor("amount", { header: "Amount" }),
])

export function DataTable({ data }: { data: Payment[] }) {
  const table = useTable({ features, data, columns })

  return (
    <div className="overflow-hidden rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead key={header.id}>
                  {header.isPlaceholder ? null : (
                    <table.FlexRender header={header} />
                  )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    <table.FlexRender cell={cell} />
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}
Cell formatting
A column's cell function formats what renders — here the amount becomes a currency string, end-aligned along with its header via text-end. Use row.getValue() for the column value or row.original for the full row.
StatusEmail
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
success
Silas22@example.com
$874.00
failed
carmella@example.com
$721.00
const columns = columnHelper.columns([
  columnHelper.accessor("status", {
    header: "Status",
    cell: ({ row }) => (
      <div className="capitalize">{row.getValue("status")}</div>
    ),
  }),
  columnHelper.accessor("email", {
    header: "Email",
    cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
  }),
  columnHelper.accessor("amount", {
    header: () => <div className="text-end">Amount</div>,
    cell: ({ row }) => {
      const formatted = new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(Number(row.getValue("amount")))

      return <div className="text-end font-medium">{formatted}</div>
    },
  }),
])
Row actions
Add a display column that renders a DropdownMenu per row — row.original gives the cell access to the full record for handlers like copy or delete. enableHiding: false keeps it out of the column-visibility toggle.
StatusEmail
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
success
Silas22@example.com
$874.00
failed
carmella@example.com
$721.00
import { DotsThreeIcon } from "@phosphor-icons/react"

import { Button } from "@workspace/ui/components/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"

const columns = columnHelper.columns([
  // ...
  columnHelper.display({
    id: "actions",
    enableHiding: false,
    cell: ({ row }) => {
      const payment = row.original

      return (
        <div className="text-end">
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" size="icon-sm">
                <DotsThreeIcon />
                <span className="sr-only">Open menu</span>
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="w-40">
              <DropdownMenuLabel>Actions</DropdownMenuLabel>
              <DropdownMenuItem
                onClick={() => navigator.clipboard.writeText(payment.id)}
              >
                Copy payment ID
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuItem>View customer</DropdownMenuItem>
              <DropdownMenuItem>View payment details</DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      )
    },
  }),
])
Pagination
With rowPaginationFeature and the paginated row model registered, rows split into pages of 10 automatically — set initialState.pagination to change the page size (3 here). Wire controls to table.previousPage() and table.nextPage().
StatusEmail
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
const table = useTable({
  features,
  data,
  columns,
  initialState: { pagination: { pageIndex: 0, pageSize: 3 } },
})

<div className="flex items-center justify-end gap-2 py-4">
  <Button
    variant="outline"
    size="sm"
    onClick={() => table.previousPage()}
    disabled={!table.getCanPreviousPage()}
  >
    Previous
  </Button>
  <Button
    variant="outline"
    size="sm"
    onClick={() => table.nextPage()}
    disabled={!table.getCanNextPage()}
  >
    Next
  </Button>
</div>
Sorting
Hold SortingState in React state and pass it back through onSortingChange and state.sorting. Turn a header into a sort control by rendering a ghost Button that calls column.toggleSorting() — click Email to flip between ascending and descending.
Status
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
success
Silas22@example.com
$874.00
failed
carmella@example.com
$721.00
import { type SortingState } from "@tanstack/react-table"
import { ArrowsDownUpIcon } from "@phosphor-icons/react"

const [sorting, setSorting] = React.useState<SortingState>([])

const table = useTable({
  features,
  data,
  columns,
  onSortingChange: setSorting,
  state: { sorting },
})

// In the columns definition:
columnHelper.accessor("email", {
  header: ({ column }) => (
    <Button
      variant="ghost"
      onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
    >
      Email
      <ArrowsDownUpIcon />
    </Button>
  ),
})
Filtering
Bind an Input to table.getColumn("email") via getFilterValue / setFilterValue. The includesString filter function registered in the features object does the matching.
StatusEmail
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
success
Silas22@example.com
$874.00
failed
carmella@example.com
$721.00
import { type ColumnFiltersState } from "@tanstack/react-table"

const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])

const table = useTable({
  features,
  data,
  columns,
  onColumnFiltersChange: setColumnFilters,
  state: { columnFilters },
})

<Input
  placeholder="Filter emails..."
  value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
  onChange={(event) =>
    table.getColumn("email")?.setFilterValue(event.target.value)
  }
  className="max-w-sm"
/>
Column visibility
columnVisibilityFeature powers a Columns dropdown: list every column where getCanHide() is true as a DropdownMenuCheckboxItem toggling column.toggleVisibility().
StatusEmail
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
success
Silas22@example.com
$874.00
failed
carmella@example.com
$721.00
import { type ColumnVisibilityState } from "@tanstack/react-table"
import { CaretDownIcon } from "@phosphor-icons/react"

const [columnVisibility, setColumnVisibility] =
  React.useState<ColumnVisibilityState>({})

const table = useTable({
  features,
  data,
  columns,
  onColumnVisibilityChange: setColumnVisibility,
  state: { columnVisibility },
})

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline">
      Columns <CaretDownIcon />
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end">
    {table
      .getAllColumns()
      .filter((column) => column.getCanHide())
      .map((column) => (
        <DropdownMenuCheckboxItem
          key={column.id}
          className="capitalize"
          checked={column.getIsVisible()}
          onCheckedChange={(value) => column.toggleVisibility(!!value)}
        >
          {column.id}
        </DropdownMenuCheckboxItem>
      ))}
  </DropdownMenuContent>
</DropdownMenu>
Row selection
A display column of Checkboxes drives rowSelectionFeature — the header toggles the whole page, each cell toggles its row, and selected rows tint via data-state="selected" on TableRow. Read counts from getFilteredSelectedRowModel().
StatusEmail
Amount
success
ken99@example.com
$316.00
success
Abe45@example.com
$242.00
processing
Monserrat44@example.com
$837.00
success
Silas22@example.com
$874.00
failed
carmella@example.com
$721.00
0 of 5 row(s) selected.
import { Checkbox } from "@workspace/ui/components/checkbox"

const [rowSelection, setRowSelection] = React.useState({})

const table = useTable({
  features,
  data,
  columns,
  onRowSelectionChange: setRowSelection,
  state: { rowSelection },
})

// In the columns definition:
columnHelper.display({
  id: "select",
  header: ({ table }) => (
    <Checkbox
      checked={
        table.getIsAllPageRowsSelected() ||
        (table.getIsSomePageRowsSelected() && "indeterminate")
      }
      onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
      aria-label="Select all"
    />
  ),
  cell: ({ row }) => (
    <Checkbox
      checked={row.getIsSelected()}
      onCheckedChange={(value) => row.toggleSelected(!!value)}
      aria-label="Select row"
    />
  ),
  enableSorting: false,
  enableHiding: false,
})

// Below the table:
<div className="text-sm text-muted-foreground">
  {table.getFilteredSelectedRowModel().rows.length} of{" "}
  {table.getFilteredRowModel().rows.length} row(s) selected.
</div>
Project structure
Split the pattern across focused modules: column definitions, the shared features object, the table component, and the page that fetches data. If the same table shows up in multiple places, extract DataTable into a reusable component that takes columns and data props.
app
└── payments
    ├── columns.tsx             // "use client" — column definitions
    ├── data-table-features.ts  // shared tableFeatures() object
    ├── data-table.tsx          // "use client" — <DataTable /> component
    └── page.tsx                // server component — fetches data

Tokens used

16 design tokens consumed by the Data Table pattern.

  • --muted

    Muted

    bg-muted/50hover:bg-muted/50data-[state=selected]:bg-muted
  • --foreground

    Foreground

    text-foreground
  • --muted-foreground

    Muted Foreground

    text-muted-foregroundplaceholder:text-muted-foreground
  • --border

    Border

    borderborder-b
  • --input

    Input

    border-inputdark:bg-input/30
  • --ring

    Ring

    focus-visible:border-ringfocus-visible:ring-ring/50
  • --primary

    Primary

    data-checked:bg-primarydata-checked:border-primary
  • --primary-foreground

    Primary Foreground

    data-checked:text-primary-foreground
  • --accent

    Accent

    hover:bg-accentfocus:bg-accent
  • --accent-foreground

    Accent Foreground

    hover:text-accent-foregroundfocus:text-accent-foreground
  • --popover

    Popover

    bg-popover
  • --popover-foreground

    Popover Foreground

    text-popover-foreground
  • --text-sm

    Text Small (14px)

    text-sm
  • --font-weight-medium

    Font weight Medium (500)

    font-medium
  • --radius-md

    Radius 0.8× (8px)

    rounded-md
  • --spacing

    Spacing unit (4px)

    h-10p-2px-2py-4gap-2h-24