Numbers & Data Displayv0.1.0
Area Chart
The line draws on, with a snapping crosshair and tooltip.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/area-chart.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/area-chart/area-chart.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type FocusEvent, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { ease } from "@/lib/motion"; export interface AreaSeries { /** Stable identity across updates. */ id: string; /** Name in the legend and tooltip. */ label: string; /** One value per x label. */ values: number[]; /** Line color. Defaults to --chart-1, --chart-2, --chart-3 by position; pass it explicitly if series come and go. */ color?: string; } export interface AreaChartProps extends Omit<ComponentProps<"figure">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** X-axis labels, one per point (e.g. dates). */ labels: string[]; /** One to three series on a shared value axis. */ series: AreaSeries[]; /** Accessible name; also the caption of the screen-reader table. */ label: string; /** Total height in px, including the x-axis labels. @default 240 */ height?: number; /** Formats values on the axis and in the tooltip. */ formatValue?: (value: number) => string; /** Fill a soft wash under each line. @default true */ area?: boolean; /** Smooth monotone curves that never overshoot the data. @default true */ curved?: boolean; /** Most x labels to show. @default 6 */ maxTicks?: number; } const COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"]; const X_BAND = 26; const TOP = 10; const RIGHT = 8; const SNAP = { type: "spring", stiffness: 520, damping: 40 } as const; const full = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); const f = (n: number) => Math.round(n * 100) / 100; function ticksFor(max: number, count = 4) { const raw = Math.max(max, 1e-9) / count; const power = 10 ** Math.floor(Math.log10(raw)); const n = raw / power; const step = (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * power; const top = Math.max(step, Math.ceil(max / step) * step); return Array.from({ length: Math.round(top / step) + 1 }, (_, i) => i * step); } /** Monotone cubic curve through the points (or straight segments). */ function linePath(points: [number, number][], curved: boolean) { const n = points.length; if (n === 0) return ""; let d = `M${f(points[0][0])},${f(points[0][1])}`; if (n === 1) return d; if (!curved) return d + points.slice(1).map(([x, y]) => `L${f(x)},${f(y)}`).join(""); const dx: number[] = []; const m: number[] = []; for (let i = 0; i < n - 1; i++) { dx.push(points[i + 1][0] - points[i][0]); m.push((points[i + 1][1] - points[i][1]) / dx[i]); } const t = [m[0]]; for (let i = 1; i < n - 1; i++) { t.push(m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i])); } t.push(m[n - 2]); for (let i = 0; i < n - 1; i++) { const h = dx[i] / 3; const [x0, y0] = points[i]; const [x1, y1] = points[i + 1]; d += `C${f(x0 + h)},${f(y0 + t[i] * h)},${f(x1 - h)},${f(y1 - t[i + 1] * h)},${f(x1)},${f(y1)}`; } return d; } export function AreaChart({ labels, series, label, height = 240, formatValue = (v) => full.format(v), area = true, curved = true, maxTicks = 6, className, ...props }: AreaChartProps) { const reduceMotion = useReducedMotion(); const boxRef = useRef<HTMLDivElement>(null); const inView = useInView(boxRef, { once: true, amount: 0.35 }); const [width, setWidth] = useState(0); const [active, setActive] = useState<number | null>(null); const hintId = useId(); const gradientId = useId(); useEffect(() => { const box = boxRef.current; if (!box) return; const observer = new ResizeObserver(() => setWidth(box.clientWidth)); observer.observe(box); return () => observer.disconnect(); }, []); const n = labels.length; const colored = series.map((s, i) => ({ ...s, color: s.color ?? COLORS[i % COLORS.length] })); const peak = Math.max(0, ...colored.flatMap((s) => s.values)); const ticks = ticksFor(peak, 4); const top = ticks[ticks.length - 1]; const tickText = (v: number) => (top >= 10_000 ? compact.format(v) : full.format(v)); const gutter = Math.max(...ticks.map((t) => tickText(t).length)) * 6.6 + 12; const plotH = height - X_BAND - TOP; const plotW = Math.max(0, width - gutter - RIGHT); const x = (i: number) => gutter + (n > 1 ? (i / (n - 1)) * plotW : plotW / 2); const y = (v: number) => TOP + (1 - v / (top || 1)) * plotH; const baseline = TOP + plotH; // Evenly stepped x labels, plus the last point when it isn't crowded by the previous label. // As many as fit (about one per 64px), never more than maxTicks. const fit = Math.max(2, Math.min(maxTicks, Math.floor(plotW / 64))); const xStep = n <= fit ? 1 : Math.ceil((n - 1) / (fit - 1)); const xTicks = Array.from({ length: Math.floor((n - 1) / xStep) + 1 }, (_, j) => j * xStep); if (n > 1 && xTicks[xTicks.length - 1] !== n - 1) { const room = ((n - 1 - xTicks[xTicks.length - 1]) / (n - 1)) * plotW; if (room >= 56) xTicks.push(n - 1); else xTicks[xTicks.length - 1] = n - 1; } const shown = reduceMotion || inView; const snap = reduceMotion ? { duration: 0 } : SNAP; const indexAt = (clientX: number) => { const box = boxRef.current; if (!box || n === 0) return null; const px = clientX - box.getBoundingClientRect().left; return Math.min(n - 1, Math.max(0, Math.round(((px - gutter) / (plotW || 1)) * (n - 1)))); }; const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (!n) return; const step = ({ ArrowRight: 1, ArrowLeft: -1 } as Record<string, number>)[event.key]; if (step !== undefined) setActive((a) => (a === null ? (step > 0 ? 0 : n - 1) : Math.min(n - 1, Math.max(0, a + step)))); else if (event.key === "Home") setActive(0); else if (event.key === "End") setActive(n - 1); else if (event.key === "Escape") setActive(null); else return; event.preventDefault(); }; const readout = active === null ? "" : `${labels[active]}: ${colored.map((s) => `${s.label} ${formatValue(s.values[active] ?? 0)}`).join(", ")}`; const px = active === null ? 0 : x(active); const flip = px > gutter + plotW / 2; return ( <figure className={cn("w-full", className)} {...props}> {colored.length > 1 ? ( <ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> {colored.map((s) => ( <li key={s.id} className="flex items-center gap-1.5"> <span aria-hidden="true" className="h-0.5 w-3 rounded-full" style={{ background: s.color }} /> {s.label} </li> ))} </ul> ) : null} <div ref={boxRef} tabIndex={0} role="group" aria-roledescription="area chart" aria-label={label} aria-describedby={hintId} onKeyDown={onKeyDown} onFocus={(event: FocusEvent<HTMLDivElement>) => { if (event.currentTarget.matches(":focus-visible")) setActive((a) => a ?? n - 1); }} onBlur={() => setActive(null)} onPointerMove={(event: PointerEvent<HTMLDivElement>) => setActive(indexAt(event.clientX))} onPointerLeave={() => setActive(null)} className="relative touch-pan-y rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4" style={{ height }} > {width > 0 ? ( <div aria-hidden="true"> <svg width={width} height={height} className="absolute inset-0 overflow-visible"> {ticks.map((t) => ( <g key={t}> <line x1={gutter} x2={width} y1={Math.round(y(t)) + 0.5} y2={Math.round(y(t)) + 0.5} stroke="var(--border)" /> <text x={gutter - 8} y={y(t)} textAnchor="end" dominantBaseline="middle" fontSize={11} fill="var(--muted-foreground)" className="tabular-nums"> {tickText(t)} </text> </g> ))} {xTicks.map((i, j) => ( <text key={i} x={x(i)} y={height - 6} textAnchor={j === 0 && n > 1 ? "start" : j === xTicks.length - 1 && n > 1 ? "end" : "middle"} fontSize={11} fill="var(--muted-foreground)" > {labels[i]} </text> ))} </svg> {/* Lines draw on with a left-to-right clip reveal; data changes morph the paths. */} <motion.div className="absolute inset-0" initial={reduceMotion ? false : { clipPath: `inset(-8px ${width}px -8px -8px)` }} animate={shown ? { clipPath: "inset(-8px -8px -8px -8px)" } : undefined} transition={{ duration: 1.1, ease: ease.out }} > <svg width={width} height={height} className="overflow-visible"> {/* Each wash fades toward the baseline, so overlapping series stay clean instead of muddying. */} <defs> {colored.map((s, i) => ( <linearGradient key={s.id} id={`${gradientId}-${i}`} x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor={s.color} stopOpacity={0.16} /> <stop offset="100%" stopColor={s.color} stopOpacity={0.01} /> </linearGradient> ))} </defs> {colored.map((s, si) => { const points = s.values.slice(0, n).map((v, i): [number, number] => [x(i), y(v)]); const d = linePath(points, curved); const fillD = points.length > 1 ? `${d}L${f(points[points.length - 1][0])},${f(baseline)}L${f(points[0][0])},${f(baseline)}Z` : ""; return ( <g key={s.id}> {area && fillD ? ( <motion.path initial={false} animate={{ d: fillD }} transition={{ duration: 0.5, ease: ease.out }} fill={`url(#${gradientId}-${si})`} /> ) : null} <motion.path initial={false} animate={{ d }} transition={{ duration: 0.5, ease: ease.out }} fill="none" stroke={s.color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> </g> ); })} </svg> </motion.div> {active !== null ? ( <> <svg width={width} height={height} className="pointer-events-none absolute inset-0 overflow-visible"> {/* The crosshair snaps between x positions with transforms only. */} <motion.g initial={{ x: px }} animate={{ x: px }} transition={snap}> <line x1={0} x2={0} y1={TOP} y2={baseline} stroke="var(--muted-foreground)" strokeOpacity={0.45} /> {colored.map((s) => ( <motion.circle key={s.id} r={4} fill={s.color} stroke="var(--background)" strokeWidth={2} initial={{ y: y(s.values[active] ?? 0) }} animate={{ y: y(s.values[active] ?? 0) }} transition={snap} /> ))} </motion.g> </svg> <motion.div className={cn( "pointer-events-none absolute top-1 left-0 z-10 min-w-32 rounded-md border border-border bg-popover px-2.5 py-2 text-xs shadow-md", flip && "-translate-x-full", )} initial={{ x: flip ? px - 12 : px + 12 }} animate={{ x: flip ? px - 12 : px + 12 }} transition={snap} > <div className="mb-1 text-muted-foreground">{labels[active]}</div> <ul className="space-y-0.5"> {colored.map((s) => ( <li key={s.id} className="flex items-center gap-2"> <span className="h-0.5 w-2.5 shrink-0 rounded-full" style={{ background: s.color }} /> <span className="font-semibold text-foreground tabular-nums">{formatValue(s.values[active] ?? 0)}</span> <span className="text-muted-foreground">{s.label}</span> </li> ))} </ul> </motion.div> </> ) : null} </div> ) : null} </div> <p id={hintId} className="sr-only"> Use the left and right arrow keys to read each point. </p> <p aria-live="polite" className="sr-only"> {readout} </p> <table className="sr-only"> <caption>{label}</caption> <thead> <tr> <th scope="col">Label</th> {colored.map((s) => ( <th key={s.id} scope="col"> {s.label} </th> ))} </tr> </thead> <tbody> {labels.map((l, i) => ( <tr key={`${l}-${i}`}> <th scope="row">{l}</th> {colored.map((s) => ( <td key={s.id}>{formatValue(s.values[i] ?? 0)}</td> ))} </tr> ))} </tbody> </table> </figure> ); }
Usage
import { AreaChart } from "@/components/ui/area-chart/area-chart";
export function Traffic() {
return (
<AreaChart
label="Visitors by device"
labels={["Jan", "Feb", "Mar", "Apr"]}
series={[
{ id: "desktop", label: "Desktop", values: [4200, 4630, 5120, 4980] },
{ id: "mobile", label: "Mobile", values: [3100, 3480, 3920, 4410] },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
labels* | string[] | – | X-axis labels, one per point (e.g. dates). |
series* | AreaSeries[] | – | One to three series on a shared value axis. |
label* | string | – | Accessible name; also the caption of the screen-reader table. |
height | number | 240 | Total height in px, including the x-axis labels. |
formatValue | (value: number) => string | – | Formats values on the axis and in the tooltip. |
area | boolean | true | Fill a soft wash under each line. |
curved | boolean | true | Smooth monotone curves that never overshoot the data. |
maxTicks | number | 6 | Most x labels to show. |
Also accepts every native <figure> attribute, including ref and className.
- One value axis, always starting at zero. Put measures with different units in separate charts rather than adding a second axis.
- The crosshair snaps to the nearest x, and one tooltip lists every series there, value first. A legend appears from two series up.
- Lines draw on with a clip-path reveal and morph when the data changes (keep the point count the same for a smooth morph). Curves are monotone, so they never overshoot the data.
- Series colors default to --chart-1…3 by position; pass `color` per series if series can be toggled, so each keeps its color.
Accessibility
- A visually hidden table holds every value for every series.
- The chart is one tab stop: left and right arrows move the crosshair between points, and the tooltip's contents are announced through a live region.
- With reduced motion enabled, lines appear without drawing on and the crosshair moves without easing.