Morph Chart
Installation
npx shadcn@latest add https://uibrik.com/r/morph-chart.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/morph-chart/MorphChart.tsx"use client"; import { useId, useMemo, useRef, useState } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue, } from "motion/react"; import NumberFlow from "@number-flow/react"; import { cn } from "@/lib/utils"; import { ChartCard, Headline } from "../_charts/parts"; import { accentVars, GRID as GRID_STROKE } from "../_charts/theme"; /* ------------------------------------------------------------------ */ /* Data */ /* ------------------------------------------------------------------ */ export type ChartDatum = { /** Axis label, e.g. "Jan". */ label: string; /** Shown in the readout, e.g. "January". Defaults to `label`. */ name?: string; value: number; }; export const SAMPLE_CHART_DATA: ChartDatum[] = [ { label: "Jan", name: "January", value: 4200 }, { label: "Feb", name: "February", value: 6800 }, { label: "Mar", name: "March", value: 5500 }, { label: "Apr", name: "April", value: 9100 }, { label: "May", name: "May", value: 7400 }, { label: "Jun", name: "June", value: 6300 }, { label: "Jul", name: "July", value: 8500 }, ]; /* ------------------------------------------------------------------ */ /* Geometry */ /* ------------------------------------------------------------------ */ const VB_W = 520; // matches the card's content width, so the chart renders 1:1 const VB_H = 284; // cartesian plot const PLOT_X0 = 48; const PLOT_X1 = VB_W - 16; const BASE_Y = 250; const PLOT_H = 200; const DOT_R = 4; const STAGGER = 0.05; // per-item delay, as a share of a phase // donut const CX = VB_W / 2; const CY = VB_H / 2; const R_IN = 80; const THICK = 40; const STRIP_W = 2 * Math.PI * R_IN; // inner edge length, becomes inner circumference const STRIP_X0 = (VB_W - STRIP_W) / 2; const STRIP_Y = CY + R_IN; // strip top edge == bottom of the inner circle const GAP = 3; /** `n` is the corner exponent: 2 = circle, 4 = squircle. */ type Rect = { x0: number; y0: number; x1: number; y1: number; r: number; n: number }; type Pt = [number, number]; const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v)); const smooth = (x: number) => x * x * (3 - 2 * x); const easeOut = (x: number) => 1 - (1 - x) * (1 - x); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; /** Offsets item i inside a 0..1 phase so items move one after another. */ const stagger = (x: number, i: number, n: number) => clamp((x - i * STAGGER) / (1 - (n - 1) * STAGGER)); const lerpRect = (a: Rect, b: Rect, t: number): Rect => ({ x0: lerp(a.x0, b.x0, t), y0: lerp(a.y0, b.y0, t), x1: lerp(a.x1, b.x1, t), y1: lerp(a.y1, b.y1, t), r: lerp(a.r, b.r, t), n: lerp(a.n, b.n, t), }); // Everything that depends on the data, built once per dataset. function buildChart(data: ChartDatum[]) { const VALUES = data.map((d) => d.value); const N = VALUES.length; const TOTAL = VALUES.reduce((a, b) => a + b, 0) || 1; // round the axis up to the next whole step of the largest value's magnitude const max = Math.max(...VALUES, 1); const mag = 10 ** Math.floor(Math.log10(max)); const Y_MAX = Math.ceil(max / mag) * mag; const SLOT = (PLOT_X1 - PLOT_X0) / N; const BAR_W = Math.min(40, SLOT * 0.62); // Largest slice gets the strongest tint. const sorted = [...VALUES].sort((a, b) => b - a); const RANK = VALUES.map((v) => sorted.indexOf(v)); const barTop = (i: number) => BASE_Y - (VALUES[i] / Y_MAX) * PLOT_H; const slotCenter = (i: number) => PLOT_X0 + SLOT * (i + 0.5); const BAR_RECTS: Rect[] = VALUES.map((_, i) => ({ x0: slotCenter(i) - BAR_W / 2, x1: slotCenter(i) + BAR_W / 2, y0: barTop(i), y1: BASE_Y, r: Math.min(10, BAR_W / 4), n: 4, })); const DOT_RECTS: Rect[] = VALUES.map((_, i) => ({ x0: slotCenter(i) - DOT_R, x1: slotCenter(i) + DOT_R, y0: barTop(i) - DOT_R, y1: barTop(i) + DOT_R, r: DOT_R, n: 2, })); let acc = 0; const STRIP_RECTS: Rect[] = VALUES.map((v) => { const w = (v / TOTAL) * STRIP_W; const rect = { x0: STRIP_X0 + acc + GAP / 2, x1: STRIP_X0 + acc + w - GAP / 2, y0: STRIP_Y, y1: STRIP_Y + THICK, r: 8, n: 4, }; acc += w; return rect; }); return { data, VALUES, N, TOTAL, Y_MAX, SLOT, RANK, GRID: [0, Y_MAX / 2, Y_MAX], barTop, slotCenter, BAR_RECTS, DOT_RECTS, STRIP_RECTS, }; } type Chart = ReturnType<typeof buildChart>; const EDGE = 14; // points per straight edge, so edges can bend smoothly const ARC = 8; // points per rounded corner /** Rounded rect as a fixed-topology polygon. */ function roundedRect(s: Rect): Pt[] { const r = Math.max(0, Math.min(s.r, (s.x1 - s.x0) / 2, (s.y1 - s.y0) / 2)); const corners: { cx: number; cy: number; a: number }[] = [ { cx: s.x0 + r, cy: s.y0 + r, a: Math.PI }, { cx: s.x1 - r, cy: s.y0 + r, a: Math.PI * 1.5 }, { cx: s.x1 - r, cy: s.y1 - r, a: 0 }, { cx: s.x0 + r, cy: s.y1 - r, a: Math.PI * 0.5 }, ]; // superellipse corner: |x|^n + |y|^n = r^n, tangent to both edges const e = 2 / s.n; const pow = (v: number) => Math.sign(v) * Math.abs(v) ** e; const at = (c: (typeof corners)[number], da: number): Pt => [ c.cx + r * pow(Math.cos(c.a + da)), c.cy + r * pow(Math.sin(c.a + da)), ]; const pts: Pt[] = []; corners.forEach((c, k) => { for (let j = 0; j < ARC; j++) pts.push(at(c, (j / (ARC - 1)) * (Math.PI / 2))); const from = at(c, Math.PI / 2); const to = at(corners[(k + 1) % 4], 0); for (let j = 1; j <= EDGE; j++) { const f = j / (EDGE + 1); pts.push([lerp(from[0], to[0], f), lerp(from[1], to[1], f)]); } }); return pts; } /** * Bends the flat strip into a ring. Centered on the strip so the ends curl * up and meet at 12 o'clock. t = 0 leaves the point untouched. */ function bend([x, y]: Pt, t: number): Pt { if (t < 1e-4) return [x, y]; const rb = R_IN / t; const k = t / R_IN; const rho = rb + (y - STRIP_Y); const a = k * (x - CX); return [CX + rho * Math.sin(a), STRIP_Y - rb + rho * Math.cos(a)]; } const f1 = (n: number) => n.toFixed(1); /** Smooth line through the points that never overshoots between them (monotone cubic). */ function smoothLine(pts: Pt[]): string { const n = pts.length; const h = pts.slice(1).map((p, k) => p[0] - pts[k][0]); const d = pts.slice(1).map((p, k) => (p[1] - pts[k][1]) / h[k]); const m = pts.map((_, k) => { if (k === 0) return d[0]; if (k === n - 1) return d[n - 2]; if (d[k - 1] * d[k] <= 0) return 0; return ( (3 * (h[k - 1] + h[k])) / ((2 * h[k] + h[k - 1]) / d[k - 1] + (h[k] + 2 * h[k - 1]) / d[k]) ); }); let out = `M${f1(pts[0][0])} ${f1(pts[0][1])}`; for (let k = 0; k < n - 1; k++) { const [x0, y0] = pts[k]; const [x1, y1] = pts[k + 1]; const w = h[k] / 3; out += `C${f1(x0 + w)} ${f1(y0 + m[k] * w)} ${f1(x1 - w)} ${f1(y1 - m[k + 1] * w)} ${f1(x1)} ${f1(y1)}`; } return out; } const toPath = (pts: Pt[]) => "M" + pts.map(([x, y]) => `${f1(x)} ${f1(y)}`).join("L") + "Z"; /* ------------------------------------------------------------------ */ /* Frame: everything the SVG needs for one (pie, line) pair */ /* ------------------------------------------------------------------ */ /** * Two weights describe every view: `pie` (0 to 1) and `line` (0 to 1). * bar = (0, 0) · pie = (1, 0) · line = (0, 1). Both weights animate at once, * so pie <-> line goes straight across and never shows the bars. */ const VIEWS = [ { id: "pie", label: "Pie", pie: 1, line: 0 }, { id: "bar", label: "Bar", pie: 0, line: 0 }, { id: "line", label: "Line", pie: 0, line: 1 }, ] as const; type ViewId = (typeof VIEWS)[number]["id"]; type Frame = { paths: string[]; fills: string[]; grid: number; lineD: string; lineDraw: number; }; function computeFrame(chart: Chart, P: number, L: number): Frame { const { N, BAR_RECTS, DOT_RECTS, STRIP_RECTS, RANK, VALUES, slotCenter, barTop } = chart; // The hop's own ease shapes the overall speed. Phases overlap slightly and // each item only decelerates into place, so motion never stops between them. const stripPhase = clamp(P / 0.5); // bars or dots -> flat strip const bendT = smooth(clamp((P - 0.42) / 0.58)); // strip -> ring const dotPhase = clamp(L / 0.5); // bars -> dots // hold the stroke back until the strip has dissolved (pie -> line direct) const lineDraw = smooth(clamp((L - 0.35) / 0.65)) * (1 - stripPhase); const paths: string[] = []; const fills: string[] = []; for (let i = 0; i < N; i++) { const rect = lerpRect( lerpRect(BAR_RECTS[i], DOT_RECTS[i], easeOut(stagger(dotPhase, i, N))), STRIP_RECTS[i], easeOut(stagger(stripPhase, i, N)), ); paths.push(toPath(roundedRect(rect).map((p) => bend(p, bendT)))); const tint = lerp(100, 100 - RANK[i] * 10, smooth(P)); fills.push(`color-mix(in oklab, var(--series-1) ${tint.toFixed(0)}%, var(--background))`); } const tops: Pt[] = VALUES.map((_, i) => [slotCenter(i), barTop(i)]); const lineD = smoothLine(tops); return { paths, fills, grid: 1 - clamp(P / 0.3), lineD, lineDraw, }; } /* ------------------------------------------------------------------ */ /* Components */ /* ------------------------------------------------------------------ */ function Datum({ chart, i, frame, grid, dim, onHover, }: { chart: Chart; i: number; frame: MotionValue<Frame>; grid: MotionValue<number>; dim: boolean; onHover: (i: number | null) => void; }) { const d = useTransform(frame, (f) => f.paths[i]); const fill = useTransform(frame, (f) => f.fills[i]); return ( <g> <g style={{ opacity: dim ? 0.5 : 1, transition: "opacity 150ms" }}> <motion.path d={d} style={{ fill }} onPointerEnter={() => onHover(i)} onPointerLeave={() => onHover(null)} /> </g> <motion.text x={chart.slotCenter(i)} y={BASE_Y + 20} textAnchor="middle" dominantBaseline="middle" style={{ opacity: grid }} className="pointer-events-none fill-muted-foreground text-[12px] font-medium" > {chart.data[i].label} </motion.text> </g> ); } export type ChartView = (typeof VIEWS)[number]["id"]; export type MorphChartProps = { data?: ChartDatum[]; /** View shown first. */ defaultView?: ChartView; /** Readout label when nothing is hovered. */ caption?: string; /** ISO currency code for the values, or empty for plain numbers. */ currency?: string; /** Seconds for the shortest morph; longer jumps take a little more. */ duration?: number; /** Bar, line and slice colour. Empty uses the chart palette. */ accent?: string; className?: string; }; /** One dataset that morphs between bar, line and donut charts. */ export function MorphChart({ data = SAMPLE_CHART_DATA, defaultView = "bar", caption: totalCaption = "Total revenue", currency = "USD", duration = 0.4, accent = "", className, }: MorphChartProps) { const chart = useMemo(() => buildChart(data), [data]); const { VALUES, N, TOTAL, Y_MAX, SLOT, GRID, barTop, slotCenter } = chart; const format = useMemo( () => currency ? ({ style: "currency", currency, maximumFractionDigits: 0 } as const) : ({ maximumFractionDigits: 0 } as const), [currency], ); const start = VIEWS.find((v) => v.id === defaultView) ?? VIEWS[1]; const reduced = useReducedMotion(); const pillId = useId(); const pie = useMotionValue<number>(start.pie); const line = useMotionValue<number>(start.line); const controls = useRef<AnimationPlaybackControls[]>([]); const [view, setView] = useState<ViewId>(start.id); const [hover, setHover] = useState<number | null>(null); // last hovered item, so guide line and donut readout fade out in place const [guide, setGuide] = useState(N - 1); const frame = useTransform([pie, line], ([p, l]: number[]) => computeFrame(chart, p, l)); const grid = useTransform(frame, (f) => f.grid); const lineDraw = useTransform(frame, (f) => f.lineDraw); const lineOpacity = useTransform(frame, (f) => (f.lineDraw > 0.001 ? 1 : 0)); const lineD = useTransform(frame, (f) => f.lineD); const hoverAt = (i: number | null) => { setHover(i); if (i !== null) setGuide(i); }; const go = (id: ViewId) => { if (id === view) return; setView(id); hoverAt(null); controls.current.forEach((c) => c.stop()); const target = VIEWS.find((v) => v.id === id)!; if (reduced) { pie.set(target.pie); line.set(target.line); return; } const dist = Math.max( Math.abs(pie.get() - target.pie), Math.abs(line.get() - target.line), ); const opts = { duration: duration * (1 + dist * 0.625), ease: [0.4, 0, 0.2, 1] } as const; controls.current = [ animate(pie, target.pie, opts), animate(line, target.line, opts), ]; }; const onTabKeyDown = (e: React.KeyboardEvent) => { const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!step) return; e.preventDefault(); const idx = VIEWS.findIndex((v) => v.id === view); const next = VIEWS[(idx + step + VIEWS.length) % VIEWS.length]; go(next.id); (e.currentTarget.querySelector(`[data-view="${next.id}"]`) as HTMLElement)?.focus(); }; const onChartKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") return hoverAt(null); const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!step) return; e.preventDefault(); if (hover === null) return hoverAt(step === 1 ? 0 : N - 1); hoverAt(clamp(hover + step, 0, N - 1)); }; const isPie = view === "pie"; const value = hover === null ? TOTAL : VALUES[hover]; const caption = hover === null ? totalCaption : (data[hover].name ?? data[hover].label); const tabs = ( <div role="tablist" aria-label="Chart type" onKeyDown={onTabKeyDown} className="flex rounded-[10px] bg-foreground/5 p-0.5" > {VIEWS.map((v) => { const active = v.id === view; return ( <button key={v.id} data-view={v.id} role="tab" type="button" aria-selected={active} tabIndex={active ? 0 : -1} onClick={() => go(v.id)} className={cn( "relative h-6 cursor-pointer rounded-lg px-2 text-xs font-medium outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-foreground/20", active ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {active && ( <motion.span layoutId={pillId} transition={reduced ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.3 }} className="absolute inset-0 rounded-lg bg-background shadow-[0_1px_2px_rgb(0_0_0/0.08)] dark:bg-foreground/12" /> )} <span className="relative">{v.label}</span> </button> ); })} </div> ); return ( <ChartCard title={caption} value={<Headline value={value} format={format} />} aside={tabs} className={cn("max-w-[564px]", className)} style={accentVars(accent)} > <div className="relative"> <svg viewBox={`0 0 ${VB_W} ${VB_H}`} role="group" aria-label={`Monthly revenue as a ${view} chart. Use left and right arrow keys to inspect a month.`} tabIndex={0} className="h-auto w-full overflow-visible rounded-lg outline-none focus-visible:outline-2 focus-visible:outline-offset-8 focus-visible:outline-foreground/30" onPointerLeave={() => hoverAt(null)} onKeyDown={onChartKeyDown} onBlur={() => hoverAt(null)} > <motion.g style={{ opacity: grid }}> {GRID.map((g) => { const y = BASE_Y - (g / Y_MAX) * PLOT_H; return ( <g key={g}> <line x1={PLOT_X0} x2={PLOT_X1} y1={y} y2={y} stroke={GRID_STROKE} /> <text x={PLOT_X0 - 12} y={y} textAnchor="end" dominantBaseline="middle" className="fill-muted-foreground text-[12px] font-medium" > {g === 0 ? "0" : g >= 1000 ? `${+(g / 1000).toFixed(1)}k` : g} </text> </g> ); })} </motion.g> {/* hover guide for the cartesian views */} <motion.line y1={BASE_Y - PLOT_H - 10} y2={BASE_Y} initial={false} animate={{ x1: slotCenter(guide), x2: slotCenter(guide), opacity: hover !== null && !isPie ? 1 : 0, }} transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }} className="pointer-events-none stroke-foreground/20" /> {/* ring around the hovered point in the line view */} <motion.circle r={DOT_R + 5} fill="none" strokeWidth={1.5} stroke="var(--series-1)" initial={false} animate={{ cx: slotCenter(guide), cy: barTop(guide), opacity: hover !== null && view === "line" ? 0.35 : 0, }} transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }} className="pointer-events-none" /> {/* wider hover targets for the cartesian views */} {VALUES.map((_, i) => ( <rect key={i} x={PLOT_X0 + SLOT * i} y={BASE_Y - PLOT_H - 10} width={SLOT} height={PLOT_H + 20} fill="transparent" pointerEvents={isPie ? "none" : "all"} onPointerEnter={() => hoverAt(i)} /> ))} <motion.path d={lineD} fill="none" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" stroke="var(--series-1)" style={{ pathLength: lineDraw, opacity: lineOpacity }} className="pointer-events-none" /> {VALUES.map((_, i) => ( <Datum key={i} chart={chart} i={i} frame={frame} grid={grid} dim={hover !== null && hover !== i} onHover={hoverAt} /> ))} </svg> {/* donut hole readout, on hover only */} <div aria-hidden className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-0.5 transition-opacity duration-150" style={{ opacity: isPie && hover !== null ? 1 : 0 }} > <NumberFlow value={VALUES[guide] / TOTAL} locales="en-US" format={{ style: "percent", maximumFractionDigits: 0 }} className="text-3xl font-semibold tabular-nums tracking-tight text-foreground" /> <span className="text-xs font-medium text-muted-foreground"> {data[guide]?.name ?? data[guide]?.label} </span> </div> </div> <span className="sr-only" aria-live="polite"> {caption}: {new Intl.NumberFormat("en-US", format).format(value)} </span> </ChartCard> ); }
Adds the files to
components/uibrik/morph-chart and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/morph-chart.Props
MorphChart- defaultView
- Type "bar" | "line" | "pie"
- "bar"
- View shown first.
- caption
- Type string
- "Total revenue"
- Readout label when nothing is hovered.
- currency
- Type "USD" | "EUR" | "GBP" | "INR" | ""
- "USD"
- Currency for the values, or none for plain numbers.
- duration
- Type number
- 0.4
- Length of the shortest morph.
- accent
- Type string
- ""
- Bar, line and slice colour. Empty uses the chart palette.
- data
- Type ChartDatum[]
- SAMPLE_CHART_DATA
- Points: label, optional full name, value.
- className
- Type string
- –
- Classes for the card.