Skip to content

Panel

A titled region with a header action row and an optional flush body for tables and lists.

Layoutstarterlayoutsectionsettingsdashboard

Live preview

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

Source

The exact file rendered in the preview above.

import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Table } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { DemoStage } from './_kit'

const keys = [
  { id: 'k1', label: 'Production', created: '12 Jan 2026', scope: 'read-write' },
  { id: 'k2', label: 'CI pipeline', created: '03 Feb 2026', scope: 'read-only' },
  { id: 'k3', label: 'Local dev', created: '28 Feb 2026', scope: 'read-only' },
]

export default function PanelDemo() {
  return (
    <DemoStage>
      <Panel
        title="API keys"
        description="Keys are shown once at creation."
        action={
          <Button size="sm" variant="outline">
            New key
          </Button>
        }
        flush
      >
        <Table
          caption="API keys for this workspace"
          rowKey={(row) => row.id}
          rows={keys}
          responsive="stack"
          columns={[
            {
              key: 'label',
              header: 'Label',
              cell: (row) => <span className="font-medium">{row.label}</span>,
            },
            { key: 'created', header: 'Created', cell: (row) => row.created },
            {
              key: 'scope',
              header: 'Scope',
              cell: (row) => (
                <Badge tone={row.scope === 'read-write' ? 'warning' : 'neutral'}>{row.scope}</Badge>
              ),
            },
          ]}
        />
      </Panel>

      <Panel title="Danger zone" description="Actions here cannot be undone.">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <p className="text-sm text-ink-muted">
            Deleting a workspace removes every project and log.
          </p>
          <Button size="sm" variant="destructive">
            Delete workspace
          </Button>
        </div>
      </Panel>
    </DemoStage>
  )
}

components/demos/panel.tsx

import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Table } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { DemoStage } from './_kit'

const keys = [
  { id: 'k1', label: 'Production', created: '12 Jan 2026', scope: 'read-write' },
  { id: 'k2', label: 'CI pipeline', created: '03 Feb 2026', scope: 'read-only' },
  { id: 'k3', label: 'Local dev', created: '28 Feb 2026', scope: 'read-only' },
]

export default function PanelDemo() {
  return (
    <DemoStage>
      <Panel
        title="API keys"
        description="Keys are shown once at creation."
        action={
          <Button size="sm" variant="outline">
            New key
          </Button>
        }
        flush
      >
        <Table
          caption="API keys for this workspace"
          rowKey={(row) => row.id}
          rows={keys}
          responsive="stack"
          columns={[
            {
              key: 'label',
              header: 'Label',
              cell: (row) => <span className="font-medium">{row.label}</span>,
            },
            { key: 'created', header: 'Created', cell: (row) => row.created },
            {
              key: 'scope',
              header: 'Scope',
              cell: (row) => (
                <Badge tone={row.scope === 'read-write' ? 'warning' : 'neutral'}>{row.scope}</Badge>
              ),
            },
          ]}
        />
      </Panel>

      <Panel title="Danger zone" description="Actions here cannot be undone.">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <p className="text-sm text-ink-muted">
            Deleting a workspace removes every project and log.
          </p>
          <Button size="sm" variant="destructive">
            Delete workspace
          </Button>
        </div>
      </Panel>
    </DemoStage>
  )
}

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>
  )
}

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>
  )
}

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

Usage

For a named region of a page: a settings group, a dashboard widget, a table with a title. Where a Card is one of many, a Panel is one of one.

  • Use `flush` when the body is a table or a divided list, so the rows reach the panel edge.
  • Set `headingLevel` to keep the document outline correct — a panel inside a section is usually `h3`.
  • The action slot is for one or two controls, not a toolbar.

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.

  • With description
  • With header action
  • Flush — body manages its own padding

Accessibility

Landmark
Renders as a `section` by default; give it a heading so the region is nameable.
Heading level
Configurable rather than hard-coded, because a component cannot know its depth.

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.