ChartCard
Preview
Basic
Loading…
Preview
Code
ts
import ChartCard from '@/components/data/ChartCard';src/components/data/ChartCard.tsx
AI prompt
text
Build a chart card component in React + TypeScript + Tailwind CSS: the frame a chart sits in, with a title, an info hint, a legend and a one-click table view of the same numbers.
## Look
- Card: panel surface, `p-5`. Header row (`flex flex-wrap items-center gap-x-3 gap-y-2 mb-3`): the title as a section title (10px semibold uppercase, wide tracking, slate-500), followed by a 14px lucide `Info` icon button (slate-400, hover slate-600) that shows the hint in a wide tooltip on hover AND focus.
- Legend: shown only for two or more entries, and only in chart view (a single series needs none, the title names it). An inline wrapping list, 11px slate-600 / dark slate-300, `gap-x-3`. Each entry is a swatch then the label. Swatch shapes: `rect` a 10px `rounded-sm` square (default), `line` a 14×3px `rounded-full` stroke, `dot` an 8px circle.
- Right end (`ml-auto`): an optional actions slot, then a two-button segmented toggle — lucide `BarChart3` and `Table2` at 14px, `p-1.5`, inside a `rounded-lg` bordered group (slate-200 / dark slate-700, overflow hidden). Active: `bg-slate-100 text-slate-700` (dark `bg-slate-700 text-slate-100`). Inactive: slate-400, hover slate-600 (dark hover slate-200). The toggle is two small icons so the chart stays the first thing seen.
- Table view replaces the plot: a `max-h-[320px]` scroll box with a `rounded-lg` border. Sticky header row (10px semibold uppercase, `bg-slate-50/95` / dark `bg-slate-900/95`, bottom border), body text-xs with row dividers slate-100 / dark slate-800, `px-3` cells. Right-aligned columns get `tabular-nums`; a missing value prints "—".
- Empty: a 240px-tall box with "No data for the selected period." centred, text-xs slate-400 / dark slate-500.
## Companion tooltip
Also export a `ChartTooltip` for Recharts' `<Tooltip content>`: an opaque floating card, `min-w-[8rem] px-3 py-2 text-xs shadow-lg`. An optional label line on top (11px slate-500). Then one row per series: a 14×3px rounded stroke in the series colour (a stroke, not a filled box — at tooltip size a box is ink doing a label's job), the VALUE first in semibold tabular-nums slate-900 / dark slate-50, then the series name in slate-500. Props: `active`, `label`, `payload`, `format(v: number)`, `labelFormat(l)`. Renders nothing when inactive or the payload is empty.
## Behaviour
- Local state `'chart' | 'table'`, starting on chart. The toggle is hidden when no `table` is passed.
- The table is not optional polish: it is the view that works for a screen reader, for a colour-blind reader, and for anyone who wants the exact number — so a hover tooltip is never the only way to read a value.
## API
`title: string`, `hint?: string`, `legend?: { label: string; color: string; shape?: 'rect' | 'line' | 'dot' }[]`, `table?: { columns: { key: string; label: string; align?: 'left' | 'right'; format?: (v: unknown) => string }[]; rows: Record<string, unknown>[] }`, `actions?: ReactNode`, `empty = false`, `className?`, `children` (the plot).
## Accessibility
`<section aria-label={title}>` with the title as an `<h2>`. The info button is labelled "About {title}". The toggle is `role="group"` `aria-label="Chart or table"`; its buttons carry `aria-pressed` and `aria-label` "Show chart" / "Show table". Legend swatches are `aria-hidden`.
## Demo
"Seats by plan" with a one-sentence hint, a legend of Used (#2a78d6) and Free (#eb6834), a table of Pro 412 / 88 and Enterprise 1,904 / 96, and a placeholder child "Any chart goes here."
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useState, type ReactNode } from 'react';
import { BarChart3, Table2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { InfoTooltip } from '@/components/overlay/Tooltip';
export type ChartTableColumn = { key: string; label: string; align?: 'left' | 'right'; format?: (v: unknown) => string };
/**
* The frame every chart here sits in: a title (with its explanation behind an
* ⓘ), a legend, the plot, and a TABLE VIEW one click away.
*
* The table is not optional polish. It is the view that works for a screen
* reader, for a colour-blind reader where two hues still sit close, and for
* anyone who wants the exact number rather than a bar's length — so a
* tooltip is never the only way to read a value. The toggle is two icons, so
* the chart stays the first thing seen.
*/
export default function ChartCard({
title,
hint,
legend,
table,
actions,
empty = false,
className,
children,
}: {
title: string;
/** What the chart shows and how to read it — behind an ⓘ beside the title. */
hint?: string;
/** Series keys, for two or more series. A single series needs none: the title names it. */
legend?: { label: string; color: string; shape?: 'rect' | 'line' | 'dot' }[];
/** Rows and columns for the table view. Absent, the toggle is hidden. */
table?: { columns: ChartTableColumn[]; rows: Record<string, unknown>[] };
actions?: ReactNode;
empty?: boolean;
className?: string;
children: ReactNode;
}) {
const [view, setView] = useState<'chart' | 'table'>('chart');
return (
<section className={cn('panel p-5', className)} aria-label={title}>
<header className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<h2 className="panel-title flex items-center gap-1.5">
{title}
{hint && <InfoTooltip content={hint} label={`About ${title}`} wide />}
</h2>
{legend && legend.length > 1 && view === 'chart' && (
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600 dark:text-slate-300">
{legend.map((l) => (
<li key={l.label} className="flex items-center gap-1.5">
<span
aria-hidden
style={{ background: l.color }}
className={cn(
'shrink-0',
l.shape === 'line' ? 'h-[3px] w-3.5 rounded-full' : l.shape === 'dot' ? 'h-2 w-2 rounded-full' : 'h-2.5 w-2.5 rounded-sm',
)}
/>
{l.label}
</li>
))}
</ul>
)}
<div className="ml-auto flex items-center gap-1">
{actions}
{table && (
<div role="group" aria-label="Chart or table" className="flex overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{(['chart', 'table'] as const).map((v) => {
const Icon = v === 'chart' ? BarChart3 : Table2;
return (
<button
key={v}
type="button"
aria-pressed={view === v}
aria-label={v === 'chart' ? 'Show chart' : 'Show table'}
title={v === 'chart' ? 'Chart' : 'Table'}
onClick={() => setView(v)}
className={cn(
'p-1.5 transition-colors',
view === v
? 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-100'
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200',
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
</button>
);
})}
</div>
)}
</div>
</header>
{empty ? (
<div className="flex h-[240px] items-center justify-center text-xs text-slate-400 dark:text-slate-500">No data for the selected period.</div>
) : view === 'table' && table ? (
<div className="max-h-[320px] overflow-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table className="data-table">
<thead>
<tr>
{table.columns.map((c) => (
<th key={c.key} className={cn('px-3', c.align === 'right' && 'text-right')}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((r, i) => (
<tr key={i}>
{table.columns.map((c) => (
<td key={c.key} className={cn('px-3', c.align === 'right' && 'text-right tabular-nums')}>
{c.format ? c.format(r[c.key]) : String(r[c.key] ?? '—')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
children
)}
</section>
);
}
/**
* The hover readout the Recharts charts share: VALUE first and strong, the
* series name after it in secondary ink, keyed by a short stroke of the series
* colour rather than a filled box — at tooltip density a box is data-weight
* ink doing a label's job. One tooltip lists every series at that point.
*/
export function ChartTooltip({
active,
label,
payload,
format = (v) => String(v),
labelFormat = (l) => String(l),
}: {
active?: boolean;
label?: unknown;
payload?: readonly { name?: unknown; value?: unknown; color?: string; payload?: unknown }[];
format?: (v: number) => string;
labelFormat?: (l: unknown) => string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="panel panel-solid min-w-[8rem] px-3 py-2 text-xs shadow-lg">
{label !== undefined && label !== '' && <p className="mb-1 text-[11px] text-slate-500 dark:text-slate-400">{labelFormat(label)}</p>}
<ul className="space-y-0.5">
{payload.map((p, i) => (
<li key={i} className="flex items-center gap-2">
<span aria-hidden className="h-[3px] w-3.5 shrink-0 rounded-full" style={{ background: p.color }} />
<span className="font-semibold tabular-nums text-slate-900 dark:text-slate-50">{format(Number(p.value))}</span>
<span className="text-slate-500 dark:text-slate-400">{String(p.name ?? '')}</span>
</li>
))}
</ul>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | — | |
children* | ReactNode | — | |
hint | string | — | What the chart shows and how to read it — behind an ⓘ beside the title. |
legend | { label: string; color: string; shape?: 'rect' | 'line' | 'dot' }[] | — | Series keys, for two or more series. A single series needs none: the title names it. |
table | { columns: ChartTableColumn[]; rows: Record<string, unknown>[] } | — | Rows and columns for the table view. Absent, the toggle is hidden. |
actions | ReactNode | — | |
empty | boolean | false | |
className | string | — |