Skip to content

Dashboard chart panel

An inline-SVG bar chart with the series also exposed as a real table.

Applicationintermediatedashboardchartsvgaccessible-data

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 { Panel } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Divider } from '@/components/ui/layout'

/**
 * Dashboard chart panel
 *
 * A bar chart drawn as inline SVG rather than pulling in a charting library.
 * Foundry ships no chart dependency, so the series is also exposed as a real
 * table for assistive tech and for anyone who wants the numbers — which is the
 * correct treatment for a chart regardless of how it is drawn.
 */
const series = [
  { month: 'Oct', value: 128 },
  { month: 'Nov', value: 142 },
  { month: 'Dec', value: 131 },
  { month: 'Jan', value: 156 },
  { month: 'Feb', value: 168 },
  { month: 'Mar', value: 184 },
]

const max = Math.max(...series.map((point) => point.value))

export default function DashboardChartPanel() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Monthly recurring revenue"
        description="Thousands of USD, last six months"
        headingLevel="h2"
        action={<Badge tone="success">+12.4%</Badge>}
      >
        <div className="flex h-52 items-end gap-2 sm:gap-4" aria-hidden="true">
          {series.map((point) => (
            <div key={point.month} className="flex min-w-0 flex-1 flex-col items-center gap-2">
              <span className="font-mono text-2xs text-ink-muted tabular-nums">{point.value}</span>
              <div
                className="w-full rounded-t-sm bg-accent transition-[height] duration-300"
                style={{ height: `${(point.value / max) * 100}%` }}
              />
              <span className="label-caps text-ink-subtle">{point.month}</span>
            </div>
          ))}
        </div>

        <Divider className="my-5" weight="subtle" />

        {/* The chart is decorative; this table is the accessible content. */}
        <table className="w-full text-sm">
          <caption className="sr-only">
            Monthly recurring revenue in thousands of USD, October to March
          </caption>
          <thead>
            <tr>
              <th scope="col" className="label-caps pb-2 text-left text-ink-subtle">
                Month
              </th>
              <th scope="col" className="label-caps pb-2 text-right text-ink-subtle">
                Revenue
              </th>
            </tr>
          </thead>
          <tbody>
            {series.map((point) => (
              <tr key={point.month} className="border-t border-line-subtle">
                <th scope="row" className="py-1.5 text-left font-normal text-ink-muted">
                  {point.month}
                </th>
                <td className="py-1.5 text-right font-mono text-ink tabular-nums">
                  ${point.value},000
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </Panel>
    </section>
  )
}

components/blocks/sections/dashboard/chart-panel.tsx

import { Panel } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Divider } from '@/components/ui/layout'

/**
 * Dashboard chart panel
 *
 * A bar chart drawn as inline SVG rather than pulling in a charting library.
 * Foundry ships no chart dependency, so the series is also exposed as a real
 * table for assistive tech and for anyone who wants the numbers — which is the
 * correct treatment for a chart regardless of how it is drawn.
 */
const series = [
  { month: 'Oct', value: 128 },
  { month: 'Nov', value: 142 },
  { month: 'Dec', value: 131 },
  { month: 'Jan', value: 156 },
  { month: 'Feb', value: 168 },
  { month: 'Mar', value: 184 },
]

const max = Math.max(...series.map((point) => point.value))

export default function DashboardChartPanel() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Monthly recurring revenue"
        description="Thousands of USD, last six months"
        headingLevel="h2"
        action={<Badge tone="success">+12.4%</Badge>}
      >
        <div className="flex h-52 items-end gap-2 sm:gap-4" aria-hidden="true">
          {series.map((point) => (
            <div key={point.month} className="flex min-w-0 flex-1 flex-col items-center gap-2">
              <span className="font-mono text-2xs text-ink-muted tabular-nums">{point.value}</span>
              <div
                className="w-full rounded-t-sm bg-accent transition-[height] duration-300"
                style={{ height: `${(point.value / max) * 100}%` }}
              />
              <span className="label-caps text-ink-subtle">{point.month}</span>
            </div>
          ))}
        </div>

        <Divider className="my-5" weight="subtle" />

        {/* The chart is decorative; this table is the accessible content. */}
        <table className="w-full text-sm">
          <caption className="sr-only">
            Monthly recurring revenue in thousands of USD, October to March
          </caption>
          <thead>
            <tr>
              <th scope="col" className="label-caps pb-2 text-left text-ink-subtle">
                Month
              </th>
              <th scope="col" className="label-caps pb-2 text-right text-ink-subtle">
                Revenue
              </th>
            </tr>
          </thead>
          <tbody>
            {series.map((point) => (
              <tr key={point.month} className="border-t border-line-subtle">
                <th scope="row" className="py-1.5 text-left font-normal text-ink-muted">
                  {point.month}
                </th>
                <td className="py-1.5 text-right font-mono text-ink tabular-nums">
                  ${point.value},000
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </Panel>
    </section>
  )
}

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

Foundry ships no chart dependency, so the chart is drawn inline and the series is exposed as a table — the correct treatment for a chart regardless of how it is drawn.

  • Always provide the underlying numbers; a chart without them excludes people and prevents copying.
  • Scale bars from the series maximum, not from a fixed ceiling.

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.

  • Six-month series
  • Inline SVG bars
  • Accessible data table

Accessibility

Decorative chart
The bar chart is aria-hidden; the table is the content.
Row headers
The data table uses scope on both column and row headers.

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.