Skip to content

Dashboard data table

A flush panel with a table that becomes labelled cards below md.

ApplicationintermediateFeatureddashboardtablerecordsresponsive

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Download, Filter } from 'lucide-react'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'

/**
 * Dashboard data table
 *
 * A panel with a flush table body, so rows reach the panel edge. The table uses
 * the stack strategy: below `md` each order becomes a labelled card, which reads
 * far better than a five-column grid squeezed into 390px.
 */
interface Order {
  id: string
  reference: string
  customer: string
  status: 'operational' | 'pending' | 'degraded' | 'down'
  statusLabel: string
  totalCents: number
  placed: string
}

const orders: Order[] = [
  {
    id: 'o1',
    reference: 'QRY-4821',
    customer: 'Priya Raman',
    status: 'operational',
    statusLabel: 'Fulfilled',
    totalCents: 32800,
    placed: '14 Mar',
  },
  {
    id: 'o2',
    reference: 'QRY-4820',
    customer: 'Tomas Lindqvist',
    status: 'pending',
    statusLabel: 'Processing',
    totalCents: 18500,
    placed: '14 Mar',
  },
  {
    id: 'o3',
    reference: 'QRY-4819',
    customer: 'Amara Osei',
    status: 'degraded',
    statusLabel: 'Delayed',
    totalCents: 9800,
    placed: '13 Mar',
  },
  {
    id: 'o4',
    reference: 'QRY-4818',
    customer: 'Jun Watanabe',
    status: 'down',
    statusLabel: 'Payment failed',
    totalCents: 24500,
    placed: '13 Mar',
  },
  {
    id: 'o5',
    reference: 'QRY-4817',
    customer: 'Elena Rossi',
    status: 'operational',
    statusLabel: 'Fulfilled',
    totalCents: 5200,
    placed: '12 Mar',
  },
]

export default function DashboardDataTable() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Recent orders"
        description="Last 5 of 1,284"
        headingLevel="h2"
        flush
        action={
          <>
            <Button variant="outline" size="sm" leadingIcon={<Filter className="size-3.5" />}>
              Filter
            </Button>
            <Button variant="outline" size="sm" leadingIcon={<Download className="size-3.5" />}>
              Export
            </Button>
          </>
        }
      >
        <Table
          caption="Recent orders with customer, status, total and date"
          rows={orders}
          rowKey={(row) => row.id}
          responsive="stack"
          density="compact"
          columns={[
            {
              key: 'reference',
              header: 'Order',
              cell: (row) => (
                <code className="font-mono text-xs text-ink-strong">{row.reference}</code>
              ),
            },
            {
              key: 'customer',
              header: 'Customer',
              cell: (row) => (
                <span className="flex items-center gap-2.5">
                  <Avatar name={row.customer} size="xs" decorative />
                  <span className="truncate">{row.customer}</span>
                </span>
              ),
            },
            {
              key: 'status',
              header: 'Status',
              cell: (row) => <Status appearance="dot" kind={row.status} label={row.statusLabel} />,
            },
            {
              key: 'total',
              header: 'Total',
              align: 'end',
              cell: (row) => (
                <span className="font-mono tabular-nums">{formatCurrency(row.totalCents)}</span>
              ),
            },
            {
              key: 'placed',
              header: 'Placed',
              align: 'end',
              cell: (row) => <Badge size="sm">{row.placed}</Badge>,
            },
          ]}
        />
      </Panel>
    </section>
  )
}

components/blocks/sections/dashboard/data-table.tsx

import { Download, Filter } from 'lucide-react'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'

/**
 * Dashboard data table
 *
 * A panel with a flush table body, so rows reach the panel edge. The table uses
 * the stack strategy: below `md` each order becomes a labelled card, which reads
 * far better than a five-column grid squeezed into 390px.
 */
interface Order {
  id: string
  reference: string
  customer: string
  status: 'operational' | 'pending' | 'degraded' | 'down'
  statusLabel: string
  totalCents: number
  placed: string
}

const orders: Order[] = [
  {
    id: 'o1',
    reference: 'QRY-4821',
    customer: 'Priya Raman',
    status: 'operational',
    statusLabel: 'Fulfilled',
    totalCents: 32800,
    placed: '14 Mar',
  },
  {
    id: 'o2',
    reference: 'QRY-4820',
    customer: 'Tomas Lindqvist',
    status: 'pending',
    statusLabel: 'Processing',
    totalCents: 18500,
    placed: '14 Mar',
  },
  {
    id: 'o3',
    reference: 'QRY-4819',
    customer: 'Amara Osei',
    status: 'degraded',
    statusLabel: 'Delayed',
    totalCents: 9800,
    placed: '13 Mar',
  },
  {
    id: 'o4',
    reference: 'QRY-4818',
    customer: 'Jun Watanabe',
    status: 'down',
    statusLabel: 'Payment failed',
    totalCents: 24500,
    placed: '13 Mar',
  },
  {
    id: 'o5',
    reference: 'QRY-4817',
    customer: 'Elena Rossi',
    status: 'operational',
    statusLabel: 'Fulfilled',
    totalCents: 5200,
    placed: '12 Mar',
  },
]

export default function DashboardDataTable() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Recent orders"
        description="Last 5 of 1,284"
        headingLevel="h2"
        flush
        action={
          <>
            <Button variant="outline" size="sm" leadingIcon={<Filter className="size-3.5" />}>
              Filter
            </Button>
            <Button variant="outline" size="sm" leadingIcon={<Download className="size-3.5" />}>
              Export
            </Button>
          </>
        }
      >
        <Table
          caption="Recent orders with customer, status, total and date"
          rows={orders}
          rowKey={(row) => row.id}
          responsive="stack"
          density="compact"
          columns={[
            {
              key: 'reference',
              header: 'Order',
              cell: (row) => (
                <code className="font-mono text-xs text-ink-strong">{row.reference}</code>
              ),
            },
            {
              key: 'customer',
              header: 'Customer',
              cell: (row) => (
                <span className="flex items-center gap-2.5">
                  <Avatar name={row.customer} size="xs" decorative />
                  <span className="truncate">{row.customer}</span>
                </span>
              ),
            },
            {
              key: 'status',
              header: 'Status',
              cell: (row) => <Status appearance="dot" kind={row.status} label={row.statusLabel} />,
            },
            {
              key: 'total',
              header: 'Total',
              align: 'end',
              cell: (row) => (
                <span className="font-mono tabular-nums">{formatCurrency(row.totalCents)}</span>
              ),
            },
            {
              key: 'placed',
              header: 'Placed',
              align: 'end',
              cell: (row) => <Badge size="sm">{row.placed}</Badge>,
            },
          ]}
        />
      </Panel>
    </section>
  )
}

components/ui/table.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Table
 *
 * A real `<table>` with `<caption>`, scoped headers and semantic rows — the
 * only markup screen readers can navigate cell by cell.
 *
 * Two responsive strategies are supported, because neither works everywhere:
 *
 *   `scroll` keeps the grid and puts it in a labelled, keyboard-focusable
 *           scroll region (a scrollable area must be reachable by keyboard).
 *   `stack` collapses each row into a labelled card below `md`, which reads
 *           better for short, wide records.
 */
export interface Column<Row> {
  key: string
  header: ReactNode
  /** Cell renderer. Receives the row and its index. */
  cell: (row: Row, index: number) => ReactNode
  align?: 'start' | 'center' | 'end'
  /** Hides the column below `md` in scroll mode. */
  hideOnMobile?: boolean
  width?: string
}

export interface TableProps<Row> {
  caption: string
  /** Hides the caption visually while leaving it for assistive tech. */
  hideCaption?: boolean
  columns: Array<Column<Row>>
  rows: Row[]
  rowKey: (row: Row, index: number) => string
  responsive?: 'scroll' | 'stack'
  empty?: ReactNode
  className?: string
  density?: 'compact' | 'default'
}

const alignClass = { start: 'text-left', center: 'text-center', end: 'text-right' } as const

export function Table<Row>({
  caption,
  hideCaption = true,
  columns,
  rows,
  rowKey,
  responsive = 'scroll',
  empty,
  className,
  density = 'default',
}: TableProps<Row>) {
  if (rows.length === 0 && empty) {
    return <div className={className}>{empty}</div>
  }

  const cellPadding = density === 'compact' ? 'px-3 py-1.5' : 'px-4 py-2.5'

  const table = (
    <table className="w-full border-collapse text-sm">
      <caption className={cn('text-left text-xs text-ink-muted', hideCaption ? 'sr-only' : 'pb-3')}>
        {caption}
      </caption>
      <thead>
        <tr className="border-b border-line bg-surface-sunken">
          {columns.map((column) => (
            <th
              key={column.key}
              scope="col"
              style={column.width ? { width: column.width } : undefined}
              className={cn(
                'label-caps text-ink-muted',
                cellPadding,
                alignClass[column.align ?? 'start'],
                responsive === 'scroll' && column.hideOnMobile && 'hidden md:table-cell',
              )}
            >
              {column.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, index) => (
          <tr
            key={rowKey(row, index)}
            className="border-b border-line-subtle last:border-0 hover:bg-surface-sunken/60"
          >
            {columns.map((column) => (
              <td
                key={column.key}
                className={cn(
                  'text-ink',
                  cellPadding,
                  alignClass[column.align ?? 'start'],
                  responsive === 'scroll' && column.hideOnMobile && 'hidden md:table-cell',
                )}
              >
                {column.cell(row, index)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )

  if (responsive === 'stack') {
    return (
      <div className={className}>
        {/* Below md: one labelled card per record. */}
        <ul className="flex flex-col gap-2 md:hidden">
          {rows.map((row, index) => (
            <li key={rowKey(row, index)} className="rounded-lg border border-line bg-surface p-3">
              <dl className="flex flex-col gap-1.5">
                {columns.map((column) => (
                  <div key={column.key} className="flex items-baseline justify-between gap-3">
                    <dt className="label-caps shrink-0 text-ink-subtle">{column.header}</dt>
                    <dd className="min-w-0 text-right text-sm text-ink">
                      {column.cell(row, index)}
                    </dd>
                  </div>
                ))}
              </dl>
            </li>
          ))}
        </ul>
        <div className="hidden overflow-hidden rounded-lg border border-line md:block">{table}</div>
      </div>
    )
  }

  return (
    <div className={cn('overflow-hidden rounded-lg border border-line', className)}>
      <div
        tabIndex={0}
        role="region"
        aria-label={caption}
        className="thin-scrollbar overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2"
      >
        {table}
      </div>
    </div>
  )
}

components/ui/card.tsx

import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'

/**
 * Card & Panel
 *
 * Two containment primitives with deliberately different jobs:
 *
 *   Card  — a discrete, often interactive record in a collection.
 *   Panel — a titled region of a page, with an optional header action row.
 *
 * Keeping them separate is what stops the library from degenerating into
 * "everything is a rounded box with a shadow".
 */

export interface CardProps extends HTMLAttributes<HTMLDivElement> {
  as?: ElementType
  /** Forwarded when `as` renders a link. */
  href?: string
  /** `outline` is the default; `raised` adds elevation; `sunken` insets. */
  tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
  /** Adds hover affordance. Only use when the whole card is a link/button. */
  interactive?: boolean
  padding?: 'none' | 'sm' | 'md' | 'lg'
}

const cardTones = {
  outline: 'bg-surface border border-line',
  raised: 'bg-surface-raised border border-line shadow-sm',
  sunken: 'bg-surface-sunken border border-line-subtle',
  ghost: 'bg-transparent border border-transparent',
  accent: 'bg-accent-soft border border-accent-line',
} as const

const cardPadding = {
  none: 'p-0',
  sm: 'p-3',
  md: 'p-card',
  lg: 'p-6 sm:p-8',
} as const

export function Card({
  as: Tag = 'div',
  tone = 'outline',
  interactive = false,
  padding = 'md',
  className,
  children,
  ...props
}: CardProps) {
  return (
    <Tag
      className={cn(
        'rounded-lg',
        cardTones[tone],
        cardPadding[padding],
        interactive &&
          'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div className={cn('flex items-start justify-between gap-4', className)} {...props}>
      {children}
    </div>
  )
}

export function CardTitle({
  as: Tag = 'h3',
  className,
  children,
  ...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
  return (
    <Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
      {children}
    </Tag>
  )
}

export function CardDescription({
  className,
  children,
  ...props
}: HTMLAttributes<HTMLParagraphElement>) {
  return (
    <p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
      {children}
    </p>
  )
}

export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn(
        'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
  title: ReactNode
  description?: ReactNode
  /** Rendered on the right of the panel header. */
  action?: ReactNode
  /** Removes body padding — for tables and lists that manage their own. */
  flush?: boolean
  as?: ElementType
  headingLevel?: 'h2' | 'h3' | 'h4'
}

export function Panel({
  title,
  description,
  action,
  flush = false,
  as: Tag = 'section',
  headingLevel: Heading = 'h3',
  className,
  children,
  ...props
}: PanelProps) {
  return (
    <Tag
      className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
      {...props}
    >
      <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
        <div className="min-w-0">
          <Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
          {description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
        </div>
        {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
      </div>
      <div className={cn(flush ? '' : 'p-card')}>{children}</div>
    </Tag>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Below md each order becomes a labelled card, which reads far better than a five-column grid squeezed into 390px.

  • Use flush panels for tables so rows reach the panel edge.
  • Compact density suits admin tables; the type scale does not change with it.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Flush panel body
  • Stack strategy
  • Toolbar actions

Accessibility

Caption
The table has a caption naming the data, visually hidden by default.
Stack labels
Each stacked card repeats the column label beside the value.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.