Ridgeline Chart
NewInstallation
npx shadcn@latest add https://uibrik.com/r/ridgeline-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/ridgeline-chart/ridgeline-chart.tsx"use client"; import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, useChartCursor, useWidth } from "../_charts/parts"; import { areaPath, clamp, linePath, nearest, type Point } from "../_charts/scale"; import { accentVars, DOT, DRAW_IN, EASE_OUT, FOLLOW, GRID, SETTLE } from "../_charts/theme"; import { CHAR, xLabelIndices } from "../_charts/axis"; import { formatCompact, formatValue, type ValueFormat } from "../_charts/format"; /** One ridge: a name and a value for every x label. */ export type RidgeRow = { key: string; label: string; values: number[] }; const HOURS = Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")}:00`); const bump = (h: number, at: number, spread: number, height: number) => height * Math.exp(-((h - at) ** 2) / (2 * spread ** 2)); const SAMPLE: RidgeRow[] = [ ["mon", "Mon", 10, 3.2, 14, 4], ["tue", "Tue", 11, 3, 15, 3.6], ["wed", "Wed", 10, 2.6, 16, 3.4], ["thu", "Thu", 11, 3.4, 14, 4.2], ["fri", "Fri", 9, 3, 13, 5], ["sat", "Sat", 13, 4, 20, 3], ["sun", "Sun", 15, 4.4, 21, 3], ].map(([key, label, a, sa, b, sb], r) => ({ key: key as string, label: label as string, values: HOURS.map((_, h) => Math.round(bump(h, a as number, sa as number, 80 - r * 4) + bump(h, b as number, sb as number, 52 + r * 3) + 4)), })); const TOP = 6; const X_AXIS = 24; const VALUE_GUTTER = 44; export type RidgelineChartProps = { /** Names what is plotted. */ title?: string; /** One ridge per row, drawn top to bottom. Every row needs a value for each label. */ rows?: RidgeRow[]; /** The x axis, one label per value. */ labels?: string[]; /** Intl.NumberFormat options for every number. */ format?: ValueFormat; /** Distance between ridge baselines in px. */ pitch?: number; /** How tall the tallest peak is, as a multiple of `pitch`. Above 1 the ridges overlap. */ overlap?: number; /** Highlight colour. Empty uses the chart palette. */ accent?: string; /** Called with the label index under the cursor, or null when it leaves. */ onActiveChange?: (index: number | null) => void; className?: string; }; export function RidgelineChart({ title = "Activity by hour", rows = SAMPLE, labels = HOURS, format = {}, pitch = 38, overlap = 1.8, accent = "", onActiveChange, className, }: RidgelineChartProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const cursor = useChartCursor(labels.length); const { active } = cursor; const [row, setRow] = useState<number | null>(null); const [entered, setEntered] = useState(false); useEffect(() => { const t = setTimeout(() => setEntered(true), 900); return () => clearTimeout(t); }, []); const n = labels.length; const fmt = (v: number) => formatValue(v, format); const peak = pitch * overlap; const baseY = (r: number) => TOP + peak + r * pitch; const height = baseY(rows.length - 1) + 10 + X_AXIS; const max = Math.max(1, ...rows.flatMap((r) => r.values)); const left = Math.max(...rows.map((r) => r.label.length)) * CHAR + 12; const right = Math.max(left + 1, width - VALUE_GUTTER); const xs = labels.map((_, i) => left + ((right - left) * i) / Math.max(1, n - 1)); const points = rows.map((r, ri) => r.values.slice(0, n).map((v, i) => [xs[i], baseY(ri) - (v / max) * peak] as Point)); const xLabels = width ? xLabelIndices(labels, left, right) : []; // The focused ridge is drawn last, so it lifts in front of its neighbours. const order = rows.map((_, i) => i).sort((a, b) => Number(a === row) - Number(b === row)); const at = active !== null ? xs[active] : undefined; const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!n || !width) return; const rect = e.currentTarget.getBoundingClientRect(); cursor.point(nearest(xs, e.clientX - rect.left)); // A ridge rises above its baseline, so the pointer belongs to the first baseline below it. const r = Math.round((e.clientY - rect.top - TOP - peak + pitch * 0.35) / pitch); setRow(clamp(r, 0, rows.length - 1)); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); setRow((r) => clamp((r ?? 0) + (e.key === "ArrowDown" ? 1 : -1), 0, rows.length - 1)); return; } cursor.bind.onKeyDown(e); }; const callback = useRef(onActiveChange); useEffect(() => { callback.current = onActiveChange; }); useEffect(() => callback.current?.(active), [active]); const focusRow = row !== null ? rows[row] : null; const readout = focusRow && active !== null ? (focusRow.values[active] ?? 0) : max; const drawKey = `${rows.length}-${n}`; return ( <ChartCard title={title} value={<Headline value={readout} format={format} />} meta={ <span className="text-xs text-muted-foreground tabular-nums"> {focusRow && active !== null ? `${focusRow.label} · ${labels[active]}` : "Peak across every row"} </span> } className={cn("max-w-160", className)} style={accentVars(accent)} > <div ref={ref} role="group" aria-label={`${title}: ${rows.length} rows. Left and right arrows move along the axis, up and down switch rows.`} className="relative cursor-crosshair touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" style={{ height }} onPointerMove={onPointerMove} onPointerDown={onPointerMove} {...cursor.bind} onKeyDown={onKeyDown} onPointerLeave={() => { cursor.bind.onPointerLeave(); setRow(null); }} > {width > 0 && n > 1 && ( <svg width={width} height={height} className="block overflow-visible" aria-hidden> {at !== undefined && ( <motion.line y1={TOP} y2={height - X_AXIS} className="stroke-foreground/20" strokeWidth={1} initial={false} animate={{ x1: at, x2: at }} transition={reduce ? { duration: 0 } : FOLLOW} /> )} {order.map((ri) => { const r = rows[ri]; const pts = points[ri]; const on = row === ri; const dim = row !== null && !on; const y = baseY(ri); return ( <motion.g key={`${r.key}-${drawKey}`} initial={{ opacity: 0, y: reduce ? 0 : 10 }} animate={{ opacity: dim ? 0.4 : 1, y: on ? -5 : 0 }} transition={{ ...SETTLE, duration: reduce ? 0 : 0.35, delay: entered || reduce ? 0 : ri * 0.04, }} > {/* Solid, so a ridge hides the one behind it. */} <path d={areaPath(pts, y)} fill="var(--background)" /> <motion.path d={areaPath(pts, y)} fill="var(--series-1)" initial={false} animate={{ opacity: on ? 0.14 : 0 }} transition={{ duration: reduce ? 0 : 0.2 }} /> <line x1={left} x2={right} y1={y} y2={y} stroke={GRID} shapeRendering="crispEdges" /> <motion.path d={linePath(pts)} fill="none" strokeWidth={on ? 2 : 1.5} strokeLinecap="round" strokeLinejoin="round" className={on ? "stroke-(--series-1)" : "stroke-foreground/55"} initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduce ? 0 : DRAW_IN, ease: EASE_OUT, delay: entered || reduce ? 0 : ri * 0.04 }} /> <text x={0} y={y} className={cn("text-[11px] font-medium", on ? "fill-foreground" : "fill-muted-foreground")} > {r.label} </text> {active !== null && ( <text x={width} y={y} textAnchor="end" className={cn("text-[11px] font-medium tabular-nums", on ? "fill-foreground" : "fill-muted-foreground")} > {formatCompact(r.values[active] ?? 0, format)} </text> )} {on && active !== null && pts[active] && ( <circle cx={pts[active][0]} cy={pts[active][1]} r={DOT} fill="var(--series-1)" className="stroke-background" strokeWidth={2} /> )} </motion.g> ); })} {xLabels.map((i, j) => ( <text key={i} x={xs[i]} y={height - 4} textAnchor={j === 0 ? "start" : j === xLabels.length - 1 ? "end" : "middle"} className="fill-muted-foreground text-[11px] font-medium tabular-nums" > {labels[i]} </text> ))} </svg> )} </div> <Announce> {active !== null ? `${labels[active]}: ${rows.map((r) => `${r.label} ${fmt(r.values[active] ?? 0)}`).join(", ")}` : ""} </Announce> <DataTable caption={title} columns={["", ...rows.map((r) => r.label)]} rows={labels.map((l, i) => [l, ...rows.map((r) => fmt(r.values[i] ?? 0))])} /> </ChartCard> ); }
Adds the files to
components/uibrik/ridgeline-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/ridgeline-chart.Props
RidgelineChart- title
- Type string
- "Activity by hour"
- Names what is plotted.
- pitch
- Type number
- 38
- Distance between ridge baselines.
- overlap
- Type number
- 1.8
- Peak height as a multiple of the pitch. Above 1 the ridges overlap.
- accent
- Type string
- ""
- Highlight colour. Empty uses the chart palette.
- rows
- Type { key: string; label: string; values: number[] }[]
- –
- One ridge per row, drawn top to bottom.
- labels
- Type string[]
- –
- The x axis, one label per value.
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every number.
- onActiveChange
- Type (index: number | null) => void
- –
- Called with the label index under the cursor.
- className
- Type string
- –
- Classes for the outer shell.