Range Line
Installation
npx shadcn@latest add https://uibrik.com/r/range-line.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/range-line/range-line.tsx"use client"; import { useId, useMemo, useState, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Delta, Headline, Segmented, useChartCursor, useWidth } from "../_charts/parts"; import { areaPath, clamp, extent, linear, linePath, type Point } from "../_charts/scale"; import { accentVars, AREA, DOT, DRAW_IN, EASE_IN_OUT, EASE_OUT, FOLLOW, LINE, seriesColor, STATUS, tint } from "../_charts/theme"; import { formatCompact, formatValue, type ValueFormat } from "../_charts/format"; import { dayLabels, walk } from "../_charts/sample"; export type RangePoint = { label: string; value: number }; export type Range = { /** Key for the range, e.g. "1M". */ value: string; /** Text on its tab. */ label: string; /** One point per tick, oldest first. The last point is the current value. */ data: RangePoint[]; /** What the change covers, e.g. "past month". Defaults to "since" the first label. */ period?: string; }; // ─── Sample ─────────────────────────────────────────────────────────────────── // One seeded walk per range, all landing on the same current value, so the // headline holds still while the history behind it changes. const NOW = 24180.52; const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"]; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const clock = (minutes: number) => { const h = Math.floor(minutes / 60); const m = minutes % 60; return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`; }; const sample = (labels: string[], opts: Parameters<typeof walk>[1]) => { const values = walk(labels.length, opts); const k = NOW / values[values.length - 1]; return labels.map((label, i) => ({ label, value: Math.round(values[i] * k * 100) / 100, })); }; // Market hours in five-minute steps, 9:30 AM to 4:00 PM. const DAY = Array.from({ length: 79 }, (_, i) => clock(570 + i * 5)); // Five trading days, hourly. const WEEK = DAYS.flatMap((d) => Array.from({ length: 7 }, (_, h) => `${d} ${clock(600 + h * 60)}`)); // Weekly closes for a year, ending the week of Oct 12. const YEAR = Array.from({ length: 52 }, (_, i) => { const d = new Date(2026, 9, 12 - (51 - i) * 7); return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; }); const SAMPLE: Range[] = [ { value: "1D", label: "1D", period: "today", data: sample(DAY, { seed: 7, start: 24000, trend: -0.0001, noise: 0.0016 }), }, { value: "1W", label: "1W", period: "past week", data: sample(WEEK, { seed: 12, start: 24000, trend: 0.0008, noise: 0.004 }), }, { value: "1M", label: "1M", period: "past month", data: sample(dayLabels(30), { seed: 5, start: 24000, trend: 0.0015, noise: 0.008, }), }, { value: "3M", label: "3M", period: "past 3 months", data: sample(dayLabels(90), { seed: 21, start: 24000, trend: -0.0003, noise: 0.008, }), }, { value: "1Y", label: "1Y", period: "past year", data: sample(YEAR, { seed: 3, start: 24000, trend: 0.004, noise: 0.018 }), }, ]; // ─── Geometry ───────────────────────────────────────────────────────────────── const HEIGHT = 180; // Room above the high and below the low for their labels. const PAD_Y = 22; // Room at either end for the dot and its ring. const INSET = DOT + 6; // Rough width of one 11px tabular character, to keep labels off the edges. const CHAR = 6.4; // Every range is resampled to at least this many points, so all their paths // share one shape of `d` string and can tween into each other. const MIN_SAMPLES = 64; /** `values` stretched to `count` evenly spaced points by straight-line interpolation. */ function resample(values: readonly number[], count: number) { if (values.length === 0) return Array<number>(count).fill(0); if (values.length === 1) return Array<number>(count).fill(values[0]); return Array.from({ length: count }, (_, j) => { const t = (j / (count - 1)) * (values.length - 1); const i = Math.min(Math.floor(t), values.length - 2); return values[i] + (values[i + 1] - values[i]) * (t - i); }); } export type RangeLineProps = { /** Names what is plotted. */ title?: string; /** Tabs and their data. Every range is drawn with the same number of points so the line can morph. */ ranges?: Range[]; /** Range shown first, when uncontrolled. */ defaultRange?: string; /** Range shown, when controlled. */ range?: string; /** Called when a tab is picked. */ onRangeChange?: (range: string) => void; /** Intl.NumberFormat options for every number, e.g. { style: "currency", currency: "USD" }. */ format?: ValueFormat; /** Colour the line by direction: green when the range ends up, red when down. */ trend?: boolean; /** Line colour when `trend` is off. Empty uses the chart palette's first colour. */ accent?: string; className?: string; }; export function RangeLine({ title = "Portfolio", ranges = SAMPLE, defaultRange, range, onRangeChange, format = { style: "currency", currency: "USD" }, trend = true, accent = "", className, }: RangeLineProps) { const reduce = useReducedMotion(); const gradient = useId(); const [ref, width] = useWidth(); const [own, setOwn] = useState(defaultRange ?? ranges[2]?.value ?? ranges[0]?.value ?? ""); const key = range ?? own; const current = ranges.find((r) => r.value === key) ?? ranges[0]; const data = useMemo(() => current?.data ?? [], [current]); const cursor = useChartCursor(data.length); const { active } = cursor; const pick = (v: string) => { if (range === undefined) setOwn(v); onRangeChange?.(v); }; // One point count for every range, so switching tabs tweens the same path. const samples = useMemo(() => Math.max(MIN_SAMPLES, ...ranges.map((r) => r.data.length)), [ranges]); const first = data[0]?.value ?? 0; const last = data[data.length - 1]?.value ?? 0; const point = active !== null ? data[active] : undefined; const shown = point?.value ?? last; const change = first ? (shown - first) / first : 0; const up = last >= first; const geo = useMemo(() => { if (!width || data.length < 2) return null; const values = data.map((d) => d.value); const [lo, hi] = extent(values); const right = width - INSET; const x = linear([0, 1], [INSET, right]); const y = linear([lo, hi === lo ? lo + 1 : hi], [HEIGHT - PAD_Y, PAD_Y]); const line = resample(values, samples).map((v, j) => [x(j / (samples - 1)), y(v)] as Point); const at = (i: number) => [x(i / (data.length - 1)), y(values[i])] as Point; return { line, at, right, base: y(first), lo: { value: lo, point: at(values.indexOf(lo)) }, hi: { value: hi, point: at(values.indexOf(hi)) }, }; }, [width, data, samples, first]); const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!geo) return; const px = e.clientX - e.currentTarget.getBoundingClientRect().left; const t = (px - INSET) / (geo.right - INSET); cursor.point(clamp(Math.round(t * (data.length - 1)), 0, data.length - 1)); }; const color = trend ? (up ? STATUS.good : STATUS.critical) : seriesColor(0); const fmt = (v: number) => formatValue(v, format); const short = (v: number) => formatCompact(v, format); // Every mark paints with currentColor from one group, so a change of // direction crossfades through a CSS transition (palette variables included). const morph = reduce ? { duration: 0 } : { duration: 0.45, ease: EASE_IN_OUT }; const at = active !== null && geo ? geo.at(active) : undefined; const end = geo?.line[geo.line.length - 1]; // Keeps a centred label of `text` inside the plot, clear of both edges. const labelX = (x: number, text: string) => { const half = (text.length * CHAR) / 2; return clamp(x, half + 4, (geo?.right ?? width) - half); }; return ( <ChartCard title={title} value={<Headline value={shown} format={format} />} meta={ // Its own line, so the header keeps one height whether or not a point is picked. <Delta className="basis-full whitespace-nowrap" value={change} label={point ? point.label : (current?.period ?? (data[0] ? `since ${data[0].label}` : undefined))} /> } aside={ ranges.length > 1 && ( <Segmented options={ranges.map((r) => ({ value: r.value, label: r.label }))} value={current?.value ?? ""} onChange={pick} label="Time range" /> ) } padded={false} className={cn("max-w-120", className)} style={accentVars(accent)} > <div ref={ref} role="group" aria-label={`${title}, ${current?.label ?? ""}, ${data.length} points. Arrow keys move between them.`} className="relative cursor-crosshair touch-none overflow-hidden rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" style={{ height: HEIGHT }} onPointerMove={onPointerMove} onPointerDown={onPointerMove} {...cursor.bind} > {geo && end && ( <svg width={width} height={HEIGHT} className="block" aria-hidden> <g style={{ color, transition: reduce ? "none" : "color 300ms ease", }} > <defs> <linearGradient id={gradient} x1="0" x2="0" y1="0" y2="1"> <stop offset="0" stopColor="currentColor" stopOpacity={AREA / 100} /> <stop offset="1" stopColor="currentColor" stopOpacity={0} /> </linearGradient> </defs> {/* Opening value: a reference, so dashed and quiet. */} <motion.g initial={false} animate={{ y: geo.base }} transition={morph}> <line x1={0} x2={width} y1={0} y2={0} stroke={tint("var(--foreground)", 22)} strokeWidth={1} strokeDasharray="2 3" /> {/* Inside the plot, on the side of the line away from where the range ends. */} <motion.text x={geo.right} initial={false} animate={{ y: up ? 14 : -6 }} transition={morph} textAnchor="end" className="fill-muted-foreground text-[11px] font-medium tabular-nums" > {short(first)} </motion.text> </motion.g> <motion.path d={areaPath(geo.line, HEIGHT)} fill={`url(#${gradient})`} initial={{ opacity: reduce ? 1 : 0, d: areaPath(geo.line, HEIGHT), }} animate={{ opacity: 1, d: areaPath(geo.line, HEIGHT) }} transition={{ d: morph, opacity: { duration: 0.4, delay: reduce ? 0 : DRAW_IN * 0.5 }, }} /> <motion.path fill="none" strokeWidth={LINE} strokeLinecap="round" strokeLinejoin="round" stroke="currentColor" initial={{ pathLength: reduce ? 1 : 0, d: linePath(geo.line) }} animate={{ pathLength: 1, d: linePath(geo.line) }} transition={{ d: morph, pathLength: { duration: DRAW_IN, ease: EASE_OUT }, }} /> {/* High and low of the range, set just outside the line. */} {[ { id: "hi", ...geo.hi, dy: -9 }, { id: "lo", ...geo.lo, dy: 17 }, ].map((m) => { const text = short(m.value); return ( <motion.text key={m.id} initial={false} // When the high or low is the opening value, the baseline label already says it. animate={{ x: labelX(m.point[0], text), y: m.point[1] + m.dy, opacity: m.value === first ? 0 : 1, }} transition={{ ...morph, opacity: { duration: reduce ? 0 : 0.2 }, }} textAnchor="middle" className="fill-muted-foreground text-[11px] font-medium tabular-nums" > {text} </motion.text> ); })} {/* Crosshair and the point it lands on. */} {at && ( <motion.g initial={false} animate={{ x: at[0] }} transition={reduce ? { duration: 0 } : FOLLOW}> <line x1={0} x2={0} y1={0} y2={HEIGHT} className="stroke-foreground/15" strokeWidth={1} /> <motion.g initial={false} animate={{ y: at[1] }} transition={reduce ? { duration: 0 } : FOLLOW}> <circle r={DOT} fill="currentColor" className="stroke-background" strokeWidth={2} /> {cursor.keyboard && <circle r={DOT + 4} fill="none" className="stroke-foreground/40" strokeWidth={2} />} </motion.g> </motion.g> )} {/* Now, while nothing is picked. */} {!at && ( <motion.circle r={DOT} className="stroke-background" strokeWidth={2} fill="currentColor" style={{ transformBox: "fill-box", transformOrigin: "center", }} initial={{ cx: end[0], cy: end[1], scale: reduce ? 1 : 0 }} animate={{ cx: end[0], cy: end[1], scale: 1 }} transition={{ cy: morph, cx: morph, scale: { duration: 0.2, delay: reduce ? 0 : DRAW_IN * 0.85, }, }} /> )} </g> </svg> )} </div> <Announce>{point ? `${point.label}: ${fmt(point.value)}` : ""}</Announce> <DataTable caption={`${title}, ${current?.label ?? ""}`} columns={["Time", title]} rows={data.map((d) => [d.label, fmt(d.value)])} /> </ChartCard> ); }
Adds the files to
components/uibrik/range-line and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/range-line.Props
RangeLine- title
- Type string
- "Portfolio"
- Names what is plotted.
- defaultRange
- Type "1D" | "1W" | "1M" | "3M" | "1Y"
- "1M"
- Range shown first.
- trend
- Type boolean
- true
- Green when the range ends up, red when down.
- accent
- Type string
- ""
- Line colour when trend is off. Empty uses the chart palette.
- ranges
- Type { value: string; label: string; data: { label: string; value: number }[]; period?: string }[]
- –
- One tab per range, each with its own points, oldest first.
- range
- Type string
- –
- Range shown, when controlled.
- onRangeChange
- Type (range: string) => void
- –
- Called when a tab is picked.
- format
- Type Intl.NumberFormatOptions
- USD currency
- Formats every number, e.g. { style: "percent" }.
- className
- Type string
- –
- Classes for the outer shell.