{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ridgeline","type":"registry:ui","title":"Ridgeline","description":"Overlapping distributions, one ridge per group: hover to lift a ridge and read its quartiles, switch datasets and every curve morphs.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/ridgeline/ridgeline.tsx","type":"registry:component","target":"@components/arc/ridgeline/ridgeline.tsx","content":"\"use client\";\n\nimport { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./ridgeline.module.css\";\n\nexport interface RidgelineSeries {\n /** Stable identity. A ridge that survives a data change morphs into its new shape. */\n id: string;\n label: string;\n /** Raw observations. The ridge is a smoothed density of these values. */\n values: number[];\n}\n\n/** Use a ridgeline to compare the shape of many distributions at once, such as daily temperatures by month or response times by region. */\nexport interface RidgelineProps {\n /** One ridge per series, drawn top to bottom. */\n series: RidgelineSeries[];\n /** What is measured, such as \"Daily highs in Zurich\". Names the chart for assistive technology. */\n label: string;\n /** Unit after each value, such as \"°C\". */\n unit?: string;\n formatValue?: (value: number) => string;\n /** Value range of the axis. Defaults to the data with a little room on each side. */\n domain?: [number, number];\n /** How far a ridge may rise into the rows above, in row heights. */\n overlap?: number;\n /** Height of one row in pixels. */\n rowHeight?: number;\n /** Smoothing in value units. Defaults to Silverman's rule per series. */\n bandwidth?: number;\n /** Shades each ridge by its median, from a light tint to the full first series color, and shows the scale under the axis. */\n tint?: boolean;\n /** Controlled id of the lifted ridge. */\n active?: string | null;\n defaultActive?: string | null;\n onActiveChange?: (id: string | null) => void;\n emptyLabel?: string;\n ref?: Ref;\n className?: string;\n}\n\ntype Stats = { q1: number; median: number; q3: number; min: number; max: number; n: number; sorted: number[] };\ntype Shape = { xs: number[]; ys: number[]; row: number; q1: number; median: number; q3: number };\n\nconst { spring } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .0005) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst morph = physical({ visualDuration: .7, bounce: .08 });\nconst reveal = physical({ visualDuration: 1, bounce: 0 });\nconst glide = physical(spring.snappy, .01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };\nconst SAMPLES = 96, AXIS = 28, TIP = 14, LIFT = 6;\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst percent = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 0 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst mix = (a: number, b: number, t: number) => a + (b - a) * t;\n\nfunction quantile(sorted: number[], q: number) {\n if (!sorted.length) return 0;\n const at = (sorted.length - 1) * q, low = Math.floor(at), high = Math.ceil(at);\n return sorted[low]! + (sorted[high]! - sorted[low]!) * (at - low);\n}\nfunction statsOf(values: number[]): Stats {\n const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);\n return { sorted, n: sorted.length, min: sorted[0] ?? 0, max: sorted[sorted.length - 1] ?? 0, q1: quantile(sorted, .25), median: quantile(sorted, .5), q3: quantile(sorted, .75) };\n}\nfunction silverman(stats: Stats) {\n if (stats.n < 2) return 1;\n const mean = stats.sorted.reduce((sum, value) => sum + value, 0) / stats.n;\n const sd = Math.sqrt(stats.sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (stats.n - 1));\n const spread = Math.min(sd, (stats.q3 - stats.q1) / 1.34 || sd);\n return Math.max(1e-6, .9 * (spread || 1) * Math.pow(stats.n, -.2));\n}\nfunction niceStep(span: number, count: number) {\n const raw = span / Math.max(1, count) || 1, power = Math.pow(10, Math.floor(Math.log10(raw))), unit = raw / power;\n return (unit >= 5 ? 5 : unit >= 2 ? 2 : 1) * power;\n}\n/** Samples a Gaussian kernel density across the domain. */\nfunction density(stats: Stats, lo: number, hi: number, bandwidth: number) {\n const xs: number[] = [], ys: number[] = [];\n const norm = 1 / (Math.max(1, stats.n) * bandwidth * Math.sqrt(2 * Math.PI));\n for (let i = 0; i < SAMPLES; i++) {\n const x = lo + ((hi - lo) * i) / (SAMPLES - 1);\n let sum = 0;\n for (const value of stats.sorted) { const u = (x - value) / bandwidth; if (u > -5 && u < 5) sum += Math.exp(-.5 * u * u); }\n xs.push(x); ys.push(sum * norm);\n }\n return { xs, ys };\n}\nconst heightAt = (shape: Shape, x: number) => {\n const { xs, ys } = shape;\n if (x <= xs[0]! || x >= xs[xs.length - 1]!) return 0;\n let i = 1;\n while (i < xs.length - 1 && xs[i]! < x) i++;\n const t = (x - xs[i - 1]!) / (xs[i]! - xs[i - 1]! || 1);\n return mix(ys[i - 1]!, ys[i]!, t);\n};\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\nexport function Ridgeline({ series, label, unit = \"\", formatValue, domain, overlap = 2.4, rowHeight = 30, bandwidth, tint = true, active, defaultActive = null, onActiveChange, emptyLabel = \"No data yet\", ref, className }: RidgelineProps) {\n const reduced = useReducedMotionSafe();\n const figure = useRef(null);\n const plot = useRef(null);\n const tip = useRef(null);\n const fills = useRef(new Map());\n const lines = useRef(new Map());\n const medians = useRef(new Map());\n const lifted = useRef<{ fill: SVGPathElement | null; band: SVGPathElement | null; line: SVGPathElement | null; median: SVGLineElement | null; group: SVGGElement | null }>({ fill: null, band: null, line: null, median: null, group: null });\n const tickRefs = useRef(new Map());\n const cross = useRef(null);\n useImperativeHandle(ref, () => figure.current as HTMLElement);\n const inView = useInView(figure, { once: true, amount: .3 });\n // A true minus sign keeps negative values tight and lined up with the tabular digits.\n const format = (value: number) => formatValue ? formatValue(value) : `${grouped.format(value).replace(\"-\", \"\\u2212\")}${unit}`;\n\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const node = plot.current;\n if (!node || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => setWidth(node.clientWidth));\n observer.observe(node);\n setWidth(node.clientWidth);\n return () => observer.disconnect();\n }, []);\n\n // Targets: one density per series on a shared domain, scaled so the tallest peak of all rises `overlap` rows.\n const signature = JSON.stringify([series, domain, bandwidth]);\n const model = useMemo(() => {\n const stats = series.map(entry => statsOf(entry.values));\n const widths = stats.map(entry => bandwidth ?? silverman(entry));\n const filled = stats.map((entry, index) => ({ entry, room: 2.5 * widths[index]! })).filter(({ entry }) => entry.n);\n let lo = domain?.[0] ?? Math.min(...filled.map(({ entry, room }) => entry.min - room));\n let hi = domain?.[1] ?? Math.max(...filled.map(({ entry, room }) => entry.max + room));\n if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) { lo = 0; hi = 1; }\n if (!domain) { const step = niceStep(hi - lo, 5); lo = Math.floor(lo / step) * step; hi = Math.ceil(hi / step) * step; }\n const curves = stats.map((entry, index) => entry.n ? density(entry, lo, hi, widths[index]!) : { xs: Array.from({ length: SAMPLES }, (_, i) => lo + ((hi - lo) * i) / (SAMPLES - 1)), ys: new Array(SAMPLES).fill(0) });\n const peak = Math.max(1e-9, ...curves.flatMap(curve => curve.ys));\n const shapes = new Map(series.map((entry, index) => { const curve = curves[index]!, stat = stats[index]!; return [entry.id, { xs: curve.xs, ys: curve.ys.map(y => y / peak), row: index, q1: stat.q1, median: stat.median, q3: stat.q3 }]; }));\n return { stats: new Map(series.map((entry, index) => [entry.id, stats[index]!])), shapes, lo, hi };\n }, [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const [ownActive, setOwnActive] = useState(defaultActive);\n const current = active !== undefined ? active : ownActive;\n const setCurrent = (id: string | null) => { if (active === undefined) setOwnActive(id); if (id !== current) onActiveChange?.(id); };\n const [cursor, setCursor] = useState<{ value: number; pointerY: number | null } | null>(null);\n\n const narrow = width > 0 && width < 420;\n const gutter = narrow ? 36 : 52;\n const top = Math.ceil((overlap - 1) * rowHeight) + LIFT + 8;\n const height = top + series.length * rowHeight + AXIS;\n const plotWidth = Math.max(40, width - gutter - 4);\n const baselineOf = (row: number) => top + (row + 1) * rowHeight;\n\n // Drawn geometry lives outside React; a spring interpolates it from what is on screen to the new targets.\n const drawn = useRef({ shapes: new Map(), lo: 0, hi: 1 });\n const live = useRef({ gutter, plotWidth, rowHeight, overlap, top, current });\n useLayoutEffect(() => { live.current = { gutter, plotWidth, rowHeight, overlap, top, current }; });\n const xOf = (value: number) => { const d = drawn.current, L = live.current; return L.gutter + ((value - d.lo) / (d.hi - d.lo || 1)) * L.plotWidth; };\n\n const paint = () => {\n const d = drawn.current, L = live.current;\n const sx = (value: number) => L.gutter + ((value - d.lo) / (d.hi - d.lo || 1)) * L.plotWidth;\n const lift = L.rowHeight * L.overlap;\n const curvePath = (shape: Shape, closed: boolean, from = -Infinity, to = Infinity) => {\n const base = L.top + (shape.row + 1) * L.rowHeight;\n const points: string[] = [];\n const push = (x: number, y: number) => points.push(`${sx(x).toFixed(2)},${(base - y * lift).toFixed(2)}`);\n if (from > -Infinity) push(from, heightAt(shape, from));\n shape.xs.forEach((x, i) => { if (x > from && x < to) push(x, shape.ys[i]!); });\n if (to < Infinity) push(to, heightAt(shape, to));\n if (!points.length) return \"\";\n const first = from > -Infinity ? from : shape.xs[0]!, last = to < Infinity ? to : shape.xs[shape.xs.length - 1]!;\n return closed ? `M${sx(first).toFixed(2)},${base}L${points.join(\"L\")}L${sx(last).toFixed(2)},${base}Z` : `M${points.join(\"L\")}`;\n };\n for (const [id, shape] of d.shapes) {\n const fill = fills.current.get(id), line = lines.current.get(id), median = medians.current.get(id);\n fill?.setAttribute(\"d\", curvePath(shape, true));\n line?.setAttribute(\"d\", curvePath(shape, false));\n if (median) { const x = sx(shape.median), base = L.top + (shape.row + 1) * L.rowHeight; median.setAttribute(\"x1\", x.toFixed(2)); median.setAttribute(\"x2\", x.toFixed(2)); median.setAttribute(\"y1\", String(base)); median.setAttribute(\"y2\", (base - heightAt(shape, shape.median) * lift).toFixed(2)); }\n }\n const shape = L.current ? d.shapes.get(L.current) : undefined, lens = lifted.current;\n if (shape && lens.fill && lens.line && lens.band && lens.median) {\n lens.fill.setAttribute(\"d\", curvePath(shape, true));\n lens.line.setAttribute(\"d\", curvePath(shape, false));\n lens.band.setAttribute(\"d\", curvePath(shape, true, shape.q1, shape.q3));\n const x = sx(shape.median), base = L.top + (shape.row + 1) * L.rowHeight;\n lens.median.setAttribute(\"x1\", x.toFixed(2)); lens.median.setAttribute(\"x2\", x.toFixed(2)); lens.median.setAttribute(\"y1\", String(base)); lens.median.setAttribute(\"y2\", (base - heightAt(shape, shape.median) * lift).toFixed(2));\n }\n for (const [value, tick] of tickRefs.current) tick.style.transform = `translate3d(${sx(value).toFixed(2)}px, 0, 0)`;\n };\n const paintRef = useRef(paint);\n useLayoutEffect(() => { paintRef.current = paint; });\n\n const shown = inView || reduced;\n useEffect(() => {\n if (!shown) return;\n const d = drawn.current;\n const first = d.shapes.size === 0;\n const from = new Map();\n for (const [id, target] of model.shapes) {\n const now = d.shapes.get(id);\n from.set(id, now ? { ...now, xs: [...now.xs], ys: [...now.ys] } : { ...target, ys: target.ys.map(() => 0), q1: target.median, q3: target.median });\n }\n const fromLo = first ? model.lo : d.lo, fromHi = first ? model.hi : d.hi;\n const finish = () => { d.shapes = new Map([...model.shapes].map(([id, shape]) => [id, { ...shape }])); d.lo = model.lo; d.hi = model.hi; paintRef.current(); };\n if (reduced) { finish(); return; }\n d.shapes = new Map([...from].map(([id, shape]) => [id, { ...shape, xs: [...shape.xs], ys: [...shape.ys] }]));\n const controls = animate(0, 1, { ...(first ? reveal : morph), onUpdate: t => {\n d.lo = mix(fromLo, model.lo, t); d.hi = mix(fromHi, model.hi, t);\n for (const [id, to] of model.shapes) {\n const f = from.get(id)!, out = d.shapes.get(id)!;\n // Stagger the reveal a little from the top row down, so the ridges roll in like a wave.\n const local = first ? clamp((t - to.row * .025) / (1 - Math.min(.5, model.shapes.size * .025)), 0, 1) : t;\n for (let i = 0; i < SAMPLES; i++) { out.xs[i] = mix(f.xs[i]!, to.xs[i]!, local); out.ys[i] = mix(f.ys[i]!, to.ys[i]!, local); }\n out.row = mix(f.row, to.row, local); out.q1 = mix(f.q1, to.q1, local); out.median = mix(f.median, to.median, local); out.q3 = mix(f.q3, to.q3, local);\n }\n paintRef.current();\n }, onComplete: finish });\n return () => controls.stop();\n }, [shown, model, reduced]);\n useLayoutEffect(() => { paintRef.current(); });\n\n // Axis ticks for the target domain; their positions follow the drawn domain every frame.\n const tickCount = Math.max(2, Math.floor(plotWidth / 72));\n const step = niceStep(model.hi - model.lo, tickCount);\n const ticks: number[] = [];\n for (let value = Math.ceil(model.lo / step) * step; value <= model.hi + 1e-9; value += step) ticks.push(Math.round(value * 1e6) / 1e6 || 0);\n\n const activeStats = current ? model.stats.get(current) : undefined;\n const activeSeries = current ? series.find(entry => entry.id === current) : undefined;\n const reading = cursor && activeStats ? { value: cursor.value, below: activeStats.n ? activeStats.sorted.filter(value => value <= cursor.value).length / activeStats.n : 0 } : null;\n // The tooltip keeps its last content while it fades out, so it never collapses to an empty box on leave.\n const [held, setHeld] = useState<{ id: string; value: number | null } | null>(null);\n if (current && (held?.id !== current || held.value !== (reading?.value ?? null))) setHeld({ id: current, value: reading?.value ?? null });\n const tipSeries = held ? series.find(entry => entry.id === held.id) : undefined;\n const tipStats = held ? model.stats.get(held.id) : undefined;\n const tipBelow = held && held.value !== null && tipStats?.n ? tipStats.sorted.filter(value => value <= held.value!).length / tipStats.n : 0;\n\n // The tooltip trails the pointer or the keyboard cursor and stays inside the chart.\n const tipX = useMotionValue(0), tipY = useMotionValue(0);\n const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);\n const wasOn = useRef(false);\n useLayoutEffect(() => {\n const bubble = tip.current, shape = current ? model.shapes.get(current) : undefined;\n if (cross.current) cross.current.style.transform = `translate3d(${reading ? xOf(reading.value).toFixed(2) : 0}px, 0, 0)`;\n if (!bubble || !shape || !width) { wasOn.current = false; return; }\n const tw = bubble.offsetWidth, th = bubble.offsetHeight;\n const ax = reading ? xOf(reading.value) : xOf(shape.median);\n const ay = cursor?.pointerY ?? baselineOf(shape.row) - heightAt(shape, shape.median) * rowHeight * overlap - LIFT;\n let left = ax + TIP;\n if (left + tw > width) left = ax - TIP - tw;\n let y = ay - th - TIP;\n if (y < 0) y = ay + TIP;\n tipX.set(clamp(left, 0, Math.max(0, width - tw))); tipY.set(clamp(y, 0, Math.max(0, height - th)));\n if (!wasOn.current || reduced) { tipSpringX.jump(tipX.get()); tipSpringY.jump(tipY.get()); }\n wasOn.current = true;\n });\n\n const valueAt = (x: number) => { const d = drawn.current; return d.lo + clamp((x - gutter) / plotWidth, 0, 1) * (d.hi - d.lo); };\n const pick = (x: number, y: number) => {\n const value = valueAt(x), lift = rowHeight * overlap;\n let best: Shape | null = null, bestId: string | null = null;\n for (const [id, shape] of drawn.current.shapes) {\n const base = baselineOf(shape.row), h = heightAt(shape, value) * lift;\n if (y <= base + 2 && y >= base - h - 4 && (!best || shape.row > best.row)) { best = shape; bestId = id; }\n }\n if (!bestId) { const row = clamp(Math.floor((y - top) / rowHeight), 0, series.length - 1); bestId = series[row]?.id ?? null; }\n return { id: bestId, value };\n };\n const onPointerMove = (event: PointerEvent) => {\n const rect = event.currentTarget.getBoundingClientRect(), x = event.clientX - rect.left, y = event.clientY - rect.top;\n if (x < gutter - 8 || y > height - AXIS + 4) { if (event.pointerType === \"mouse\") { setCurrent(null); setCursor(null); } return; }\n const hit = pick(x, y);\n setCurrent(hit.id);\n setCursor({ value: hit.value, pointerY: y });\n };\n const onKeyDown = (event: KeyboardEvent) => {\n const at = current ? series.findIndex(entry => entry.id === current) : -1;\n const stats = (id: string | undefined) => id ? model.stats.get(id) : undefined;\n const choose = (index: number) => { event.preventDefault(); const next = series[clamp(index, 0, series.length - 1)]; if (!next) return; setCurrent(next.id); setCursor({ value: cursor?.value ?? stats(next.id)?.median ?? model.lo, pointerY: null }); };\n const nudge = (direction: number) => { event.preventDefault(); if (!current) { choose(0); return; } const base = cursor?.value ?? stats(current)?.median ?? model.lo; const next = clamp((Math.round(base / step) + direction) * step, model.lo, model.hi); setCursor({ value: next, pointerY: null }); };\n if (event.key === \"ArrowDown\") choose(at + 1);\n else if (event.key === \"ArrowUp\") choose(at < 0 ? 0 : at - 1);\n else if (event.key === \"Home\") choose(0);\n else if (event.key === \"End\") choose(series.length - 1);\n else if (event.key === \"ArrowRight\") nudge(1);\n else if (event.key === \"ArrowLeft\") nudge(-1);\n else if (event.key === \"Escape\" && current) { event.preventDefault(); setCurrent(null); setCursor(null); }\n };\n\n const empty = !series.length || series.every(entry => !entry.values.length);\n /** Ridge tone: one hue, the first series color, from a light tint (low median) to full strength (high median), so the scale never passes through a muddy middle. */\n const toneOf = (t: number) => `color-mix(in oklab, var(--series-1) ${Math.round(38 + clamp(t, 0, 1) * 62)}%, var(--surface))`;\n const ridgeColor = (value: number) => tint ? toneOf((value - model.lo) / (model.hi - model.lo || 1)) : \"var(--series-1)\";\n const describe = (entry: RidgelineSeries) => { const s = model.stats.get(entry.id)!; return `${entry.label}: median ${format(s.median)}, middle half ${format(s.q1)} to ${format(s.q3)}, range ${format(s.min)} to ${format(s.max)}, ${s.n} values`; };\n\n return
\n
{ if (event.pointerType === \"mouse\") { setCurrent(null); setCursor(null); } }} onKeyDown={onKeyDown} onBlur={() => { setCurrent(null); setCursor(null); }}>\n
\n {ticks.map(value =>
{ if (element) tickRefs.current.set(value, element); else tickRefs.current.delete(value); }} className={styles.tick} style={{ top: top - rowHeight * .5 }}>\n {format(value)}\n
)}\n
\n
\n \n {series.map(entry => \n { if (element) fills.current.set(entry.id, element); else fills.current.delete(entry.id); }} className={styles.fill} />\n { if (element) medians.current.set(entry.id, element); else medians.current.delete(entry.id); }} className={styles.median} />\n { if (element) lines.current.set(entry.id, element); else lines.current.delete(entry.id); }} className={styles.line} />\n )}\n { lifted.current.group = element; }} className={styles.lens} data-on={current ? true : undefined} style={tipStats ? { \"--ridge\": ridgeColor(tipStats.median) } as CSSProperties : undefined}>\n { lifted.current.fill = element; }} className={styles.lensFill} />\n { lifted.current.band = element; }} className={styles.lensBand} />\n { lifted.current.median = element; }} className={styles.lensMedian} />\n { lifted.current.line = element; }} className={styles.lensLine} />\n \n \n
\n {series.map((entry, index) => {entry.label})}\n
\n {empty &&

{emptyLabel}

}\n \n {tipSeries && tipStats && <>\n

{tipSeries.label}

\n

Median{format(tipStats.median)}

\n

Middle half{format(tipStats.q1)} to {format(tipStats.q3)}

\n

Range{format(tipStats.min)} to {format(tipStats.max)}

\n {held?.value != null &&

{percent.format(tipBelow)} at or below {format(held.value)}

}\n }\n
\n
\n {tint && !empty &&
\n Median\n {format(model.lo)}\n {[0, .25, .5, .75, 1].map(step => )}\n {format(model.hi)}\n
}\n

{activeSeries ? `${describe(activeSeries)}${reading ? `. ${percent.format(reading.below)} at or below ${format(reading.value)}` : \"\"}` : \"\"}

\n {!empty &&
\n \n \n {series.map(entry => { const s = model.stats.get(entry.id)!; return ; })}\n
{label}
SeriesMedianFirst quartileThird quartileLowestHighestCount
{entry.label}{format(s.median)}{format(s.q1)}{format(s.q3)}{format(s.min)}{format(s.max)}{s.n}
}\n
;\n}\n\nexport default Ridgeline;\n"},{"path":"registry/components/ridgeline/ridgeline.module.css","type":"registry:component","target":"@components/arc/ridgeline/ridgeline.module.css","content":".figure { display: grid; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n\n.plot { position: relative; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: crosshair; }\n.svg { position: absolute; inset: 0; display: block; overflow: visible; }\n\n/* Ridges are opaque, so each one hides the valleys of the row behind it. */\n.ridge { transition: opacity var(--duration-standard) var(--ease-standard); }\n.fill { fill: color-mix(in oklab, var(--ridge) 34%, var(--surface)); stroke: none; transition: fill var(--duration-considered) var(--ease-standard); }\n.line { fill: none; stroke: var(--ridge); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; transition: stroke var(--duration-considered) var(--ease-standard); }\n.median { stroke: color-mix(in oklab, var(--ridge) 55%, var(--surface)); stroke-width: 1; transition: stroke var(--duration-considered) var(--ease-standard); }\n.plot[data-active] .ridge[data-dim] { opacity: .38; }\n.ridge[data-lifted] { opacity: 0; transition-duration: var(--duration-instant); }\n\n/* The lifted ridge is a copy drawn above the rest, so it can rise over its neighbours. */\n.lens { opacity: 0; pointer-events: none; transform: translateY(0); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.lens[data-on] { opacity: 1; transform: translateY(-6px); }\n.lensFill { fill: color-mix(in oklab, var(--ridge) 18%, var(--surface)); stroke: none; }\n.lensBand { fill: color-mix(in oklab, var(--ridge) 48%, var(--surface)); stroke: none; }\n.lensMedian { stroke: var(--foreground); stroke-width: 1.5; stroke-linecap: round; }\n.lensLine { fill: none; stroke: var(--ridge); stroke-width: 2.25; stroke-linejoin: round; stroke-linecap: round; }\n\n.ticks { position: absolute; top: 0; right: 0; left: 0; pointer-events: none; }\n.tick { position: absolute; bottom: 0; left: 0; width: 1px; background: var(--border-subtle); will-change: transform; animation: tickIn var(--duration-standard) var(--ease-standard); }\n.tickLabel { position: absolute; bottom: 0; left: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; translate: -50% 0; }\n@keyframes tickIn { from { opacity: 0; } }\n\n.crosshair { position: absolute; left: 0; width: 1px; background: var(--foreground); opacity: 0; pointer-events: none; transition: opacity var(--duration-instant) var(--ease-standard); }\n.plot[data-cursor] .crosshair { opacity: .35; }\n\n.labels { position: absolute; inset: 0; pointer-events: none; }\n.rowLabel { position: absolute; left: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-xs); line-height: 18px; text-overflow: ellipsis; white-space: nowrap; transition: color var(--duration-fast) var(--ease-standard); }\n.rowLabel[data-active] { color: var(--foreground); font-weight: 500; }\n\n.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 170px; max-width: min(240px, 100%); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.plot[data-active] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }\n.tipValue, .tipRow { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }\n.tipValue { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: 1.4; }\n.tipRow { color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }\n.tipKey { color: var(--text-muted); font-weight: 400; }\n.tipValue .tipKey { font-size: var(--text-xs); }\n.tipNote { margin: 4px 0 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n\n.scale { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-top: var(--space-3); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.scaleLabel { margin-right: 4px; color: var(--text-secondary); }\n.steps { display: inline-flex; gap: 2px; }\n.step { width: 18px; height: 10px; border-radius: 3px; background: var(--ridge); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .ridge, .lens, .crosshair, .tooltip, .rowLabel { transition: none; }\n .tick { animation: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/ridgeline","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/ridgeline","markdown":"https://uiarc.dev/components/ridgeline/markdown","tags":["data","chart","new"]}}