data displayclient
stat card
a number on a card, with how it's changed and an optional sparkline.
monthly readers
24,891
0
vs. last 30 days
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add stat-cardor by hand: drop these files into components/ui and import from there. stat card leans on CardShell.tsx, so those come along too.
components/ui/StatCard.tsxtsx
1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import { CardShell } from "./CardShell";5
6type StatTheme = "light" | "dark";7type DeltaDirection = "up" | "down" | "neutral";8
9interface StatCardProps {10 label: string;11 value: number;12 prefix?: string;13 suffix?: string;14 decimals?: number;15 delta?: { value: string; direction: DeltaDirection };16 hint?: string;17 sparkline?: number[];18 icon?: React.ReactNode;19 theme?: StatTheme;20 className?: string;21}22
23function useAnimatedNumber(target: number, duration: number) {24 const [v, setV] = useState(0);25 const prevRef = useRef(0);26
27 useEffect(() => {28 const from = prevRef.current;29 const delta = target - from;30 if (delta === 0) return;31 // Reduced motion: show the number, skip the count32 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {33 prevRef.current = target;34 const id = requestAnimationFrame(() => setV(target));35 return () => cancelAnimationFrame(id);36 }37 const start = performance.now();38 let raf = 0;39 const tick = (now: number) => {40 const t = Math.min(1, (now - start) / duration);41 const eased = 1 - Math.pow(1 - t, 3);42 setV(from + delta * eased);43 if (t < 1) {44 raf = requestAnimationFrame(tick);45 } else {46 prevRef.current = target;47 }48 };49 raf = requestAnimationFrame(tick);50 return () => cancelAnimationFrame(raf);51 }, [target, duration]);52
53 return v;54}55
56function Sparkline({ data, dark }: { data: number[]; dark?: boolean }) {57 const uid = useId();58 if (data.length < 2) return null;59 const W = 120;60 const H = 44;61 const P = 6;62 const max = Math.max(...data);63 const min = Math.min(...data);64 const range = max - min || 1;65 const step = (W - P * 2) / (data.length - 1);66
67 const pts = data.map((d, i) => {68 const x = P + i * step;69 const y = H - P - ((d - min) / range) * (H - P * 2);70 return [x, y] as const;71 });72
73 const line = pts74 .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`)75 .join(" ");76 // The fill runs off both edges of the face so it reads as part of the surface77 const area = `M0,${pts[0][1].toFixed(1)} ${line.replace(/^M/, "L")} L${W},${pts[pts.length - 1][1].toFixed(1)} L${W},${H} L0,${H} Z`;78
79 const stroke = dark ? "#ffffff" : "#171717";80 const gradId = `spark-fade-${uid.replace(/:/g, "")}`;81 const [lx, ly] = pts[pts.length - 1];82
83 return (84 <div85 aria-hidden86 className="relative h-14"87 // Fades in from the left so the line arrives rather than starts at a hard edge88 style={{89 maskImage: "linear-gradient(90deg, transparent 0%, #000 18%)",90 WebkitMaskImage: "linear-gradient(90deg, transparent 0%, #000 18%)",91 }}92 >93 <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 w-full h-full overflow-visible">94 <defs>95 <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">96 <stop offset="0%" stopColor={stroke} stopOpacity={dark ? 0.2 : 0.08} />97 <stop offset="100%" stopColor={stroke} stopOpacity="0" />98 </linearGradient>99 </defs>100 <path d={area} fill={`url(#${gradId})`} />101 <path102 d={line}103 fill="none"104 stroke={stroke}105 strokeOpacity={dark ? 0.85 : 0.9}106 strokeWidth={1.5}107 strokeLinecap="round"108 strokeLinejoin="round"109 vectorEffect="non-scaling-stroke"110 />111 </svg>112 {/* The latest value, as a dot — drawn in HTML so it stays round when the SVG stretches */}113 <span114 className="absolute w-[7px] h-[7px] rounded-full -translate-x-1/2 -translate-y-1/2"115 style={{116 left: `${(lx / W) * 100}%`,117 top: `${(ly / H) * 100}%`,118 background: stroke,119 boxShadow: dark ? "0 0 0 3px rgba(255,255,255,0.14)" : "0 0 0 3px rgba(17,17,17,0.08)",120 }}121 />122 </div>123 );124}125
126const deltaTone: Record<DeltaDirection, { light: string; dark: string; label: string }> = {127 up: { light: "text-emerald-700", dark: "text-emerald-400", label: "up" },128 down: { light: "text-red-600", dark: "text-red-400", label: "down" },129 neutral: { light: "text-neutral-600", dark: "text-neutral-300", label: "unchanged" },130};131
132function DeltaArrow({ direction }: { direction: DeltaDirection }) {133 const d =134 direction === "up" ? "M12 19V5m0 0l-6 6m6-6l6 6" : direction === "down" ? "M12 5v14m0 0l-6-6m6 6l6-6" : "M5 12h14m0 0l-6-6m6 6l-6 6";135 return (136 <svg aria-hidden width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">137 <path strokeLinecap="round" strokeLinejoin="round" d={d} />138 </svg>139 );140}141
142/* Built on CardShell; the trend line bleeds off the bottom of the face143 like a chart on a screen. */144export function StatCard({145 label,146 value,147 prefix = "",148 suffix = "",149 decimals = 0,150 delta,151 hint,152 sparkline,153 icon,154 theme = "light",155 className = "",156}: StatCardProps) {157 const animated = useAnimatedNumber(value, 900);158 const dark = theme === "dark";159
160 const formatter = new Intl.NumberFormat("en-US", {161 minimumFractionDigits: decimals,162 maximumFractionDigits: decimals,163 });164 const finalText = `${prefix}${formatter.format(value)}${suffix}`;165
166 const badge = delta && (167 <span168 aria-label={`${deltaTone[delta.direction].label} ${delta.value}`}169 className={[170 "inline-flex items-center gap-1 h-6 px-2 rounded-[7px] text-[12px] font-medium tabular-nums shrink-0",171 dark ? deltaTone[delta.direction].dark : deltaTone[delta.direction].light,172 ].join(" ")}173 style={{174 background: dark ? "var(--tile-dark)" : "var(--face-light)",175 boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",176 }}177 >178 <DeltaArrow direction={delta.direction} />179 {delta.value}180 </span>181 );182
183 /* Label and change share the top line — what it is, and which way it's184 going. The number carries the card; the hint qualifies it in a185 quieter voice right underneath. */186 return (187 <CardShell theme={theme} className={className}>188 <div className="px-6 pt-5 flex items-center justify-between gap-3 min-h-11">189 <div className="flex items-center gap-2.5 min-w-0">190 {icon && (191 <div192 aria-hidden193 className={[194 "w-7 h-7 rounded-[9px] flex items-center justify-center shrink-0",195 dark ? "text-neutral-100" : "text-neutral-600",196 ].join(" ")}197 style={{198 background: dark ? "var(--tile-dark)" : "var(--face-light)",199 boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",200 }}201 >202 {icon}203 </div>204 )}205 <p206 className={[207 "text-[14px] font-medium tracking-[-0.01em] truncate",208 dark ? "text-neutral-300" : "text-neutral-700",209 ].join(" ")}210 >211 {label}212 </p>213 </div>214 {badge}215 </div>216
217 {/* Screen readers get the final value once, not every frame of the count */}218 <p className="sr-only">{finalText}</p>219 <p220 aria-hidden221 className={[222 "px-6 mt-5 text-[44px] font-semibold tabular-nums tracking-[-0.045em] leading-none",223 dark ? "text-white" : "text-neutral-900",224 ].join(" ")}225 >226 {prefix}227 {formatter.format(animated)}228 {/* Units are context, not the headline — set them smaller and quieter */}229 {suffix && (230 <span231 className={[232 "ml-1 text-[17px] font-medium tracking-[-0.01em]",233 dark ? "text-neutral-500" : "text-neutral-400",234 ].join(" ")}235 >236 {suffix.trim()}237 </span>238 )}239 </p>240 {hint && (241 <p className={["px-6 mt-2.5 text-[13px] truncate", dark ? "text-neutral-500" : "text-neutral-500"].join(" ")}>242 {hint}243 </p>244 )}245
246 {/* The trend line bleeds to the face's edges, like a chart on a screen */}247 {sparkline && sparkline.length > 1 ? (248 <div className="mt-auto pt-6">249 <Sparkline data={sparkline} dark={dark} />250 </div>251 ) : (252 <div className="pb-6" />253 )}254 </CardShell>255 );256}components/ui/CardShell.tsxtsx
1import React from "react";2
3type Theme = "light" | "dark";4
5interface CardShellProps {6 theme?: Theme;7 /** Controls and context: actions, attribution, deltas. Lives on the chassis. */8 rail?: React.ReactNode;9 /** "actions" for buttons; "text" for plain context, indented to the face's text edge */10 railKind?: "actions" | "text";11 as?: "div" | "figure" | "article";12 className?: string;13 faceClassName?: string;14 children: React.ReactNode;15}16
17/* Every card is built like every other object in the kit: a chassis (bezel)18 holding a face. Content goes on the face; controls and context go on the19 rail, the strip of chassis below it. The two are nearly the same tone —20 the hairline around the face is what separates them.21
22 Geometry, measured from the card's outer edge:23 - chassis 24px radius, 6px bezel → face 18px radius (concentric)24 - rail content sits 12px in from the left and bottom, 10px below the face25 - a button's label then starts 30px in — the same edge as the face's text,26 and plain-text rails are indented to that edge too */27export function CardShell({28 theme = "light",29 rail,30 railKind = "actions",31 as: Tag = "div",32 className = "",33 faceClassName = "",34 children,35}: CardShellProps) {36 const dark = theme === "dark";37
38 return (39 <Tag40 className={[41 "rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",42 rail ? "pb-0" : "",43 className,44 ].join(" ")}45 style={{46 background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",47 boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",48 ["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",49 } as React.CSSProperties}50 >51 <div52 className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}53 style={{54 background: dark ? "var(--card-dark)" : "var(--card-light)",55 boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",56 }}57 >58 {children}59 </div>60
61 {rail && (62 <div className="relative shrink-0 px-1.5 pt-2.5 pb-3">63 <div64 className={[65 // 40px = a small button's full height, so text rails and button66 // rails come out the same height and line up across a row67 "min-h-10 flex items-center gap-2 text-[12.5px]",68 railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",69 dark ? "text-neutral-400" : "text-neutral-500",70 ].join(" ")}71 >72 {rail}73 </div>74 </div>75 )}76 </Tag>77 );78}usage
tsx
import { StatCard } from "@blurrow/ui";tsx
<StatCard label="monthly readers" value={24891} delta={{ value: "12.4%", direction: "up" }} />examples
dark
subscribers
3,142
0
all-time high
bounce rate
38.4%
0.0%
vs. last 30 days
props
straight from the source, so they're always up to date.
propsts
type StatTheme = "light" | "dark";
type DeltaDirection = "up" | "down" | "neutral";
interface StatCardProps { label: string; value: number; prefix?: string; suffix?: string; decimals?: number; delta?: { value: string; direction: DeltaDirection }; hint?: string; sparkline?: number[]; icon?: React.ReactNode; theme?: StatTheme; className?: string;}