Sparkline Grid

KPI grid of labeled value tiles each paired with a compact sparkline trend. Install Sparkline Grid from the VLLNT UI registry with the shadcn CLI.

Report a bug

KPI grid of labeled value tiles each paired with a compact sparkline trend. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Sparkline Grid to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/sparkline-grid.json

Source

import * as React from "react"; import { ArrowDownRight, ArrowUpRight } from "lucide-react"; import { formatChange } from "../../lib/format"; import { cn } from "../../lib/utils"; export type SparklineGridItem = { change: number; data: number[]; label: string; value: string; }; export type SparklineGridProps = { columns?: 2 | 3 | 4; items: SparklineGridItem[]; } & React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement>; }; function buildSparklinePath(data: number[], width: number, height: number) { const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; return data .map((value, index) => { const x = data.length === 1 ? width / 2 : (index / (data.length - 1)) * width; const y = height - ((value - min) / range) * height; return `${index === 0 ? "M" : "L"}${x},${y}`; }) .join(" "); } const gridColumns = { 2: "md:grid-cols-2", 3: "md:grid-cols-2 xl:grid-cols-3", 4: "md:grid-cols-2 xl:grid-cols-4", }; export const SparklineGrid = (props: SparklineGridProps) => { const { className, columns = 2, items, ref: reference, ...rest } = props; if (items.length === 0) { return null; } return ( <div className={cn("grid grid-cols-1 gap-4", gridColumns[columns], className)} ref={reference} {...rest} > {items.map((item) => { const isPositive = item.change >= 0; const TrendIcon = isPositive ? ArrowUpRight : ArrowDownRight; const strokeClass = isPositive ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"; return ( <section className="rounded-2xl border border-border bg-card/80 p-4 shadow-sm" key={item.label} > <div className="mb-4 flex items-start justify-between gap-3"> <div> <p className="text-sm font-medium text-muted-foreground"> {item.label} </p> <p className="mt-1 text-2xl font-semibold tracking-tight text-foreground"> {item.value} </p> </div> <div className={cn( "inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium tabular-nums", isPositive ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400", )} > <TrendIcon className="size-3.5" /> {formatChange(item.change)} </div> </div> <div className="rounded-xl border border-border/60 bg-muted/20 p-3"> <svg aria-label={`${item.label} sparkline`} className="h-16 w-full" role="img" viewBox="0 0 120 48" > <path className={strokeClass} d={buildSparklinePath(item.data, 120, 48)} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" vectorEffect="non-scaling-stroke" /> </svg> </div> </section> ); })} </div> ); }; SparklineGrid.displayName = "SparklineGrid";

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0