Panel
A titled region with a header action row and an optional flush body for tables and lists.
Live preview
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.
Related
All componentsCard
A discrete record in a collection, with five tones and a composable header, title, description and footer.
starterFeatured6 variantsTable
A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.
intermediateFeatured5 variantsSwitch
An immediate on/off setting, with leading alignment for forms and trailing alignment for settings rows.
starter4 variants