charts
Registry
Sparkline
Smooth SVG trend with a scrubbable marker, value tooltip, and size tones. No Recharts.
Preview
Preview
$77.58
Sizes
Inline
Available balance
$46
Card
Volume
$85
84.6
Callout
Labeled marker
Peak balance
$61.77
Loading
Loading
Loading
Installation
Install with the shadcn CLI. This copies source into your project.
bash
npx shadcn@latest add https://livedocs.xyz/r/sparkline.jsonNamespace (after one-time setup)
npx shadcn@latest add @livedocs/sparklineUsage
tsx
import { Sparkline } from "@/components/ui/sparkline"
export function Example() {
return (
<Sparkline
size="lg"
tone="up"
format={(value) =>
value.toLocaleString("en-US", { style: "currency", currency: "USD" })
}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| isLoading | boolean | — | Show shared loading feedback instead of the sparkline |
| data | number[] | built-in sample | Series values plotted left to right |
| markerIndex | number | — | Index of the highlighted point |
| markerLabel | string | "Your balance will appear here." | Tooltip text above the marker |
| interactive | boolean | true | Scrub the marker with pointer or arrow keys |
| showValue | boolean | true | Show the numeric value in the floating tooltip |
| size | "sm" | "md" | "lg" | "lg" | Plot height for inline, card, or hero use |
| tone | "neutral" | "up" | "down" | "neutral" | Stroke and fill color from semantic tokens |
Source
"use client";
import * as React from "react";
import { ChartSkeleton, useChartReactions } from "@/components/ui/chart-reactions";
import { SadTooltipGif } from "@/components/ui/chart";
import { cn } from "@/lib/utils";
const SAMPLE = Array.from({ length: 42 }, (_, index) => {
const t = index / 41;
return (
38 +
t * 34 +
Math.sin(t * Math.PI * 2.6) * 8.5 +
Math.sin(t * Math.PI * 7.4) * 2.8 +
(index > 28 ? (index - 28) * 0.55 : 0)
);
});
const sizeClass = {
sm: "h-10",
md: "h-28",
lg: "h-[220px] md:h-[280px]",
} as const;
const toneClass = {
neutral: "text-foreground",
up: "text-[color:var(--chart-2)]",
down: "text-destructive",
} as const;
export type SparklineProps = React.ComponentProps<"div"> & {
data?: number[];
isLoading?: boolean;
markerIndex?: number;
markerLabel?: string;
interactive?: boolean;
showValue?: boolean;
format?: (value: number) => string;
size?: keyof typeof sizeClass;
tone?: keyof typeof toneClass;
};
function toPoints(data: number[], width: number, height: number) {
const min = Math.min(...data);
const max = Math.max(...data);
const span = Math.max(max - min, 1);
const padX = 10;
const padTop = height * 0.18;
const padBottom = height * 0.22;
const usable = height - padTop - padBottom;
return data.map((value, index) => {
const x =
data.length === 1
? width / 2
: padX + (index / (data.length - 1)) * (width - padX * 2);
const y = padTop + (1 - (value - min) / span) * usable;
return { x, y, value };
});
}
function smoothPath(points: Array<{ x: number; y: number }>) {
if (points.length === 0) return "";
return points
.map((point, index) => {
if (index === 0) return `M${point.x} ${point.y}`;
const previous = points[index - 1] ?? point;
const middle = (previous.x + point.x) / 2;
return `C${middle} ${previous.y} ${middle} ${point.y} ${point.x} ${point.y}`;
})
.join(" ");
}
function Sparkline({
className,
data = SAMPLE,
markerIndex,
markerLabel,
interactive = true,
showValue = true,
format = (value) =>
value.toLocaleString("en-US", { maximumFractionDigits: 1 }),
size = "lg",
tone = "neutral",
isLoading,
...props
}: SparklineProps) {
const settings = useChartReactions();
isLoading = isLoading || Boolean(settings.isLoading);
const uid = React.useId().replace(/:/g, "");
const fallbackIndex = markerIndex ?? Math.max(0, data.length - 1);
const [active, setActive] = React.useState(fallbackIndex);
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const svgRef = React.useRef<SVGSVGElement>(null);
React.useEffect(() => {
setActive(markerIndex ?? Math.max(0, data.length - 1));
}, [data.length, markerIndex]);
const width = 960;
const height = 280;
const baseline = height - 18;
const points = toPoints(data, width, height);
const line = smoothPath(points);
const last = points[points.length - 1];
const area = last
? `${line} L${last.x} ${baseline} L${points[0]?.x ?? 0} ${baseline} Z`
: "";
const marker = points[Math.min(Math.max(active, 0), Math.max(points.length - 1, 0))];
const moveTo = (clientX: number) => {
if (!interactive || !points.length || !svgRef.current) return;
setHovered(true);
const matrix = svgRef.current.getScreenCTM();
if (!matrix) return;
const x = (clientX - matrix.e) / matrix.a;
let nearest = 0;
let best = Infinity;
points.forEach((point, index) => {
const distance = Math.abs(point.x - x);
if (distance < best) {
best = distance;
nearest = index;
}
});
setActive(nearest);
};
const reset = () => {
setHovered(false);
setActive(fallbackIndex);
};
const onKeyDown = (event: React.KeyboardEvent<SVGSVGElement>) => {
if (!interactive || points.length < 2) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const delta = event.key === "ArrowLeft" ? -1 : 1;
setActive((current) =>
Math.min(points.length - 1, Math.max(0, current + delta))
);
}
if (event.key === "Home") {
event.preventDefault();
setActive(0);
}
if (event.key === "End") {
event.preventDefault();
setActive(points.length - 1);
}
};
const inspecting = interactive && (hovered || focused);
const tooltipVisible = Boolean(marker && (inspecting ? showValue : Boolean(markerLabel) || showValue));
return (
<div
className={cn(
"relative w-full rounded-lg border border-border bg-card text-card-foreground",
className
)}
{...props}
>
<ChartSkeleton isLoading={isLoading}>
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
className={cn("block w-full select-none rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", sizeClass[size], toneClass[tone])}
role={interactive && points.length ? "slider" : "img"}
aria-label="Trend"
aria-describedby={tooltipVisible ? `${uid}-tooltip` : undefined}
aria-valuemin={interactive && points.length ? 1 : undefined}
aria-valuemax={interactive && points.length ? points.length : undefined}
aria-valuenow={interactive && marker ? points.indexOf(marker) + 1 : undefined}
aria-valuetext={interactive && marker ? format(marker.value) : undefined}
tabIndex={interactive && points.length ? 0 : undefined}
onPointerDown={(event) => {
if (!interactive) return;
if (event.pointerType !== "mouse") {
event.currentTarget.setPointerCapture(event.pointerId);
}
moveTo(event.clientX);
}}
onPointerMove={(event) => {
if (!interactive) return;
if (
event.pointerType === "mouse" ||
event.currentTarget.hasPointerCapture(event.pointerId)
) {
moveTo(event.clientX);
}
}}
onPointerUp={(event) => {
if (event.pointerType !== "mouse") reset();
}}
onPointerLeave={reset}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={onKeyDown}
>
{area ? (
<>
<defs>
<linearGradient id={`${uid}-area`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.24} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<path d={area} fill={`url(#${uid}-area)`} />
</>
) : null}
<line
x1="0"
x2={width}
y1={baseline}
y2={baseline}
className="stroke-border"
strokeDasharray="4 4"
/>
<path
d={line}
fill="none"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
strokeLinecap="round"
/>
{last ? (
<circle
cx={last.x}
cy={last.y}
r="3"
fill="currentColor"
className="opacity-40"
/>
) : null}
{marker ? (
<g>
<line
x1={marker.x}
x2={marker.x}
y1="20"
y2={baseline}
className="stroke-border"
strokeWidth="2"
/>
<circle
cx={marker.x}
cy={marker.y}
r="4"
fill="currentColor"
stroke="var(--card)"
strokeWidth="2"
/>
</g>
) : null}
</svg>
{tooltipVisible && marker ? (
<div
id={`${uid}-tooltip`}
className="pointer-events-none absolute top-1 max-w-[calc(100%-1rem)] -translate-x-1/2"
style={{
left: `${Math.min(86, Math.max(14, (marker.x / width) * 100))}%`,
}}
>
<div className="flex items-center gap-2 rounded-lg border border-border bg-popover px-2.5 py-1.5 tabular-nums shadow-sm">
<SadTooltipGif />
<div>
{markerLabel && !inspecting ? (
<p className="text-xs text-muted-foreground">{markerLabel}</p>
) : null}
{showValue ? (
<p
className={cn(
"text-xs font-medium text-foreground",
markerLabel && !inspecting && "mt-0.5"
)}
>
{format(marker.value)}
</p>
) : null}
</div>
</div>
</div>
) : null}
</ChartSkeleton>
</div>
);
}
export { Sparkline, SAMPLE as sparklineSample };