Numbers & Data Displayv0.1.0
Sparkline
An inline trend line that draws on, with a change badge.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/sparkline.jsonManual
Install the dependencies
npm install clsx lucide-react 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 } }, }; }Add the theme tokens
This component uses --success, which shadcn does not define by default. Add it to your global CSS:
app/globals.css:root { --success: #15803d; } .dark { --success: #4ade80; } @theme inline { --color-success: var(--success); }Copy the source
components/ui/sparkline/sparkline.tsx"use client"; import { useRef, useState, type ComponentProps, type PointerEvent } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; import { TrendingDown, TrendingUp } from "lucide-react"; import { cn } from "@/lib/utils"; import { ease } from "@/lib/motion"; export interface SparklineProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Values, oldest first. */ data: number[]; /** One label per point (e.g. dates), shown on hover. */ labels?: string[]; /** What the line measures; starts the accessible summary. @default "Trend" */ label?: string; /** Width in px. @default 120 */ width?: number; /** Height in px. @default 36 */ height?: number; /** Line color. @default "var(--chart-1)" */ color?: string; /** Fill a soft wash under the line. @default true */ area?: boolean; /** Show the change from the first to the last value as a badge. @default false */ showChange?: boolean; /** Which direction counts as good, for the badge color. @default "up" */ goodDirection?: "up" | "down"; /** Formats values on hover and in the accessible summary. */ formatValue?: (value: number) => string; /** Draw the line on the first time it scrolls into view. @default true */ drawOn?: boolean; } const PAD = 6; // room for the end dot (r 4) and its 2px ring const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); const f = (n: number) => Math.round(n * 100) / 100; /** Monotone cubic curve: smooth, and never overshoots the data between points. */ function monotone(points: [number, number][]) { const n = points.length; if (n < 2) return n ? `M${f(points[0][0])},${f(points[0][1])}` : ""; 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]); let d = `M${f(points[0][0])},${f(points[0][1])}`; 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 Sparkline({ data, labels, label = "Trend", width = 120, height = 36, color = "var(--chart-1)", area = true, showChange = false, goodDirection = "up", formatValue = (v) => number.format(v), drawOn = true, className, ...props }: SparklineProps) { const reduceMotion = useReducedMotion(); const ref = useRef<HTMLDivElement>(null); const inView = useInView(ref, { once: true, amount: 0.6 }); const [active, setActive] = useState<number | null>(null); const min = Math.min(...data); const max = Math.max(...data); const span = max - min || 1; const step = data.length > 1 ? (width - PAD * 2) / (data.length - 1) : 0; const points = data.map((v, i): [number, number] => [PAD + i * step, PAD + (1 - (v - min) / span) * (height - PAD * 2)]); const line = monotone(points); const last = points[points.length - 1]; const first = data[0] ?? 0; const end = data[data.length - 1] ?? 0; const change = first === 0 ? 0 : ((end - first) / Math.abs(first)) * 100; const good = change === 0 ? null : (change > 0) === (goodDirection === "up"); const changeText = `${change > 0 ? "+" : ""}${change.toFixed(1)}%`; const summary = data.length ? `${label}: ${formatValue(first)} to ${formatValue(end)}${first ? ` (${changeText})` : ""}, low ${formatValue(min)}, high ${formatValue(max)}` : `${label}: no data`; const shown = !drawOn || reduceMotion || inView; const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (!data.length) return; const rect = event.currentTarget.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * width; setActive(Math.min(data.length - 1, Math.max(0, Math.round((x - PAD) / (step || 1))))); }; const point = active === null ? null : points[active]; return ( <div className={cn("inline-flex items-center gap-2", className)} {...props}> <div ref={ref} role="img" aria-label={summary} className="relative shrink-0" style={{ width, height }} onPointerMove={onPointerMove} onPointerLeave={() => setActive(null)} > {/* Draw-on is a left-to-right clip-path reveal of line and wash together. */} <motion.div className="absolute inset-0" initial={drawOn && !reduceMotion ? { clipPath: `inset(-2px ${width + 2}px -2px -2px)` } : false} animate={shown ? { clipPath: "inset(-2px -8px -2px -2px)" } : undefined} transition={{ duration: 0.9, ease: ease.out }} > <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible" aria-hidden="true"> {area && points.length > 1 ? ( <path d={`${line}L${f(last[0])},${height}L${f(points[0][0])},${height}Z`} fill={color} fillOpacity={0.1} /> ) : null} <path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> </svg> </motion.div> {last ? ( <svg width={width} height={height} className="pointer-events-none absolute inset-0 overflow-visible" aria-hidden="true"> <motion.circle cx={last[0]} cy={last[1]} r={4} fill={color} stroke="var(--background)" strokeWidth={2} initial={drawOn && !reduceMotion ? { scale: 0 } : false} animate={shown ? { scale: active === null ? 1 : 0.6 } : undefined} transition={{ type: "spring", stiffness: 500, damping: 26, delay: active === null && drawOn ? 0.75 : 0 }} /> {point ? <circle cx={point[0]} cy={point[1]} r={4} fill={color} stroke="var(--background)" strokeWidth={2} /> : null} </svg> ) : null} {point && active !== null ? ( <div aria-hidden="true" className="pointer-events-none absolute bottom-full z-10 mb-1.5 -translate-x-1/2 rounded-md bg-foreground px-1.5 py-0.5 text-[11px] whitespace-nowrap text-background shadow-md" style={{ left: Math.min(Math.max(point[0], 24), width - 24) }} > <span className="font-semibold tabular-nums">{formatValue(data[active])}</span> {labels?.[active] ? <span className="ml-1 text-background/65">{labels[active]}</span> : null} </div> ) : null} </div> {showChange && data.length > 1 ? ( <span className={cn( "inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", good === null ? "bg-muted text-foreground/65" : good ? "bg-success/10 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" : "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]", )} > {change >= 0 ? <TrendingUp className="size-3" aria-hidden="true" /> : <TrendingDown className="size-3" aria-hidden="true" />} <span className="sr-only">{change >= 0 ? "Up" : "Down"} </span> {changeText.replace("-", "−")} </span> ) : null} </div> ); }
Usage
import { Sparkline } from "@/components/ui/sparkline/sparkline";
export function Row() {
return <Sparkline data={[142, 148, 151, 158, 163, 170]} label="Northwind, 6 weeks" showChange />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
data* | number[] | – | Values, oldest first. |
labels | string[] | – | One label per point (e.g. dates), shown on hover. |
label | string | "Trend" | What the line measures; starts the accessible summary. |
width | number | 120 | Width in px. |
height | number | 36 | Height in px. |
color | string | "var(--chart-1)" | Line color. |
area | boolean | true | Fill a soft wash under the line. |
showChange | boolean | false | Show the change from the first to the last value as a badge. |
goodDirection | "up" | "down" | "up" | Which direction counts as good, for the badge color. |
formatValue | (value: number) => string | – | Formats values on hover and in the accessible summary. |
drawOn | boolean | true | Draw the line on the first time it scrolls into view. |
Also accepts every native <div> attribute, including ref and className.
- Follows the chart spec: a 2px line, a 10% wash, and an end dot with a 2px ring in the surface color.
- The draw-on is a clip-path reveal, and hovering shows the nearest point's value (and label, if you pass labels).
- Fixed size by design (width and height props); for a full-width trend use the Area Chart.
Accessibility
- Exposed as an image whose name summarises the series: first and last value, the change, and the low and high.
- The change badge pairs color with an arrow icon and a signed number, so direction never depends on color alone; its color follows goodDirection (for latency or errors, down is good).
- With reduced motion enabled, the line appears without drawing on.