Stat Card
KPI tiles whose headline numbers roll digit by digit to each new value, with Intl number, currency, percent and compact formatting, a delta badge with an up or down arrow toned by whether the move is good news, an inline sparkline that redraws when its data changes, a period caption, a skeleton loading state and a container-query StatGrid layout.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/blocks/stat-card
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useMemo } from "react";
import { Sparkline } from "@/components/motion/chart/sparkline";
import { NumberTicker } from "@/components/motion/number-ticker";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StatFormat = "number" | "currency" | "percent" | "compact";
export type StatTone = "positive" | "negative" | "neutral";
export interface StatCardProps {
label: ReactNode;
/** Headline value. For `format="percent"` pass a fraction (0.034 = 3.4%). */
value: number;
/** Preset formatting. Default "number". */
format?: StatFormat;
/** ISO 4217 code for `format="currency"`. Default "USD". */
currency?: string;
/** Formatting locale. Default "en-US" so server and client agree. */
locale?: string;
/** Extra Intl options merged over the preset (e.g. fraction digits). */
formatOptions?: Intl.NumberFormatOptions;
/** Relative change as a fraction (0.12 = +12%). Omit to hide the badge. */
delta?: number;
/** Which direction is good news. Default "up"; use "down" for churn, latency… */
goodDirection?: "up" | "down";
/** Force the badge tone instead of deriving it from delta and goodDirection. */
deltaTone?: StatTone;
/** Trend values for the inline sparkline. */
sparkline?: number[];
/** Sparkline color (any CSS color). Default follows the delta tone. */
sparklineColor?: string;
/** Small caption under the value, e.g. "vs previous 30 days". */
caption?: ReactNode;
/** Optional icon beside the label. */
icon?: ReactNode;
/** Render a skeleton instead of the value. */
loading?: boolean;
className?: string;
}
const TONE_CLASS: Record<StatTone, string> = {
positive: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
negative: "bg-destructive/10 text-destructive",
neutral: "bg-muted text-muted-foreground",
};
const TONE_COLOR: Record<StatTone, string> = {
positive: "var(--chart-3, #1baf7a)",
negative: "var(--destructive, #e5484d)",
neutral: "var(--chart-1, #2a78d6)",
};
function presetOptions(
format: StatFormat,
currency: string,
): Intl.NumberFormatOptions {
switch (format) {
case "currency":
return { style: "currency", currency, maximumFractionDigits: 0 };
case "percent":
return { style: "percent", maximumFractionDigits: 1 };
case "compact":
return { notation: "compact", maximumFractionDigits: 1 };
default:
return { maximumFractionDigits: 0 };
}
}
/**
* KPI tile: label, a headline number whose digits roll to each new value, a
* delta badge toned by whether the move is good news, an optional sparkline
* that redraws when its data changes, and a caption. `loading` swaps in a
* skeleton of the same footprint so the grid never jumps.
*/
export function StatCard({
label,
value,
format = "number",
currency = "USD",
locale = "en-US",
formatOptions,
delta,
goodDirection = "up",
deltaTone,
sparkline,
sparklineColor,
caption,
icon,
loading = false,
className,
}: StatCardProps) {
const reduce = useReducedMotion();
const formatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
...presetOptions(format, currency),
...formatOptions,
}),
[locale, format, currency, formatOptions],
);
const deltaFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}),
[locale],
);
// NumberTicker rolls integers, so feed it the value scaled by the shown
// precision and divide back in the formatter — decimals survive the roll.
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 0;
const scale = 10 ** (fractionDigits + (format === "percent" ? 2 : 0));
const tickerValue = Math.round(value * scale);
const formatTicker = (n: number) => formatter.format(n / scale);
const tone: StatTone =
deltaTone ??
(delta === undefined || delta === 0
? "neutral"
: (delta > 0) === (goodDirection === "up")
? "positive"
: "negative");
const DeltaIcon = (delta ?? 0) < 0 ? ArrowDownRight : ArrowUpRight;
const deltaText = delta === undefined ? "" : deltaFormatter.format(delta);
const sparkLabel = typeof label === "string" ? label : "Trend";
return (
<section
aria-busy={loading || undefined}
className={cn(
"flex min-w-0 flex-col rounded-2xl border border-border bg-card p-4 text-card-foreground",
className,
)}
>
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
{icon ? <span className="[&_svg]:size-3.5">{icon}</span> : null}
<span className="truncate">{label}</span>
</div>
{loading ? (
<div className="mt-2 flex flex-col gap-2" aria-hidden>
<span className="h-7 w-28 animate-pulse rounded-md bg-muted" />
<span className="h-4 w-16 animate-pulse rounded-full bg-muted" />
{sparkline ? <span className="mt-1 h-9 w-full animate-pulse rounded-md bg-muted" /> : null}
</div>
) : (
<>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<NumberTicker
value={tickerValue}
format={formatTicker}
duration={0.7}
stagger={0.03}
className="text-2xl font-semibold tracking-tight"
/>
{delta !== undefined ? (
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums transition-colors",
TONE_CLASS[tone],
)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={DeltaIcon === ArrowUpRight ? "up" : "down"}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -4 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className="inline-flex"
>
<DeltaIcon aria-hidden className="size-3" />
</motion.span>
</AnimatePresence>
<span className="sr-only">
{tone === "positive" ? "Improved " : tone === "negative" ? "Worsened " : "Changed "}
</span>
{deltaText}
</span>
) : null}
</div>
{caption ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">{caption}</p>
) : null}
{sparkline && sparkline.length > 1 ? (
<Sparkline
// Keyed by the data so a new range remounts and draws in again.
key={sparkline.join(",")}
data={sparkline}
height={36}
label={sparkLabel}
color={sparklineColor ?? TONE_COLOR[tone]}
formatValue={(v) => formatter.format(v)}
className="mt-3"
/>
) : null}
</>
)}
</section>
);
}
export interface StatGridProps {
children: ReactNode;
className?: string;
}
/**
* Container-query grid for StatCards: one column when narrow, two from
* 28rem, four from 48rem of its own width.
*/
export function StatGrid({ children, className }: StatGridProps) {
return (
<div className={cn("@container w-full", className)}>
<div className="grid grid-cols-1 gap-3 @md:grid-cols-2 @3xl:grid-cols-4">
{children}
</div>
</div>
);
}
"use client";
import { motion, useInView, useReducedMotion } from "motion/react";
import { useId, useRef } from "react";
import {
formatFull,
GROW,
useChartWidth,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export interface SparklineProps {
/** Values in time order. */
data: number[];
/** Height in px; width fills the container. Default 40. */
height?: number;
/** Any CSS color. Default: the first `--chart-*` slot. */
color?: string;
/** Soft fill under the line. Default true. */
area?: boolean;
formatValue?: (value: number) => string;
/** What the values are, e.g. "Weekly revenue". Used in the spoken summary. */
label: string;
className?: string;
}
const PAD = 5; // room for the end dot and its ring
/**
* Word-sized trend line for stat tiles. Draws in when scrolled into view and
* ends on a ringed dot at the latest value. It has no hover layer: the tile's
* headline number carries the value, and the summary below is spoken.
*/
export function Sparkline({
data,
height = 40,
color = "var(--chart-1, #2a78d6)",
area = true,
formatValue = formatFull,
label,
className,
}: SparklineProps) {
const uid = useId();
const reduce = useReducedMotion();
const [measureRef, width] = useChartWidth();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.5 });
const shown = reduce || inView;
const min = Math.min(...data);
const max = Math.max(...data);
const span = max - min || 1;
const x = (i: number) =>
PAD + (data.length > 1 ? (i / (data.length - 1)) * (width - PAD * 2) : 0);
const y = (v: number) => PAD + (1 - (v - min) / span) * (height - PAD * 2);
const line = data.map((v, i) => `${i ? "L" : "M"}${x(i)},${y(v)}`).join("");
const last = data.length - 1;
const first = data[0] ?? 0;
const end = data[last] ?? 0;
const change = first ? ((end - first) / Math.abs(first)) * 100 : 0;
const summary = `${label}: ${formatValue(first)} to ${formatValue(end)}, ${
change >= 0 ? "up" : "down"
} ${Math.abs(change).toFixed(1)}% over ${data.length} points`;
return (
<div ref={viewRef} className={cn("w-full", className)}>
<div ref={measureRef} style={{ height }} className="w-full">
{width > 0 && data.length > 1 && (
<svg width={width} height={height} role="img" aria-label={summary}>
{area && (
<>
<defs>
<linearGradient id={`${uid}-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<motion.path
d={`${line}L${x(last)},${height}L${x(0)},${height}Z`}
fill={`url(#${uid}-fill)`}
initial={false}
animate={{ opacity: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { ...GROW, delay: 0.3 }}
/>
</>
)}
<motion.path
d={line}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
initial={false}
animate={{ pathLength: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.8, ease: GROW.ease }}
/>
<motion.circle
cx={x(last)}
cy={y(end)}
r={3.5}
fill={color}
stroke="var(--card)"
strokeWidth={2}
initial={false}
animate={{ opacity: shown ? 1 : 0, scale: shown ? 1 : 0.4 }}
transition={reduce ? { duration: 0 } : { duration: 0.2, delay: 0.7 }}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
/>
</svg>
)}
</div>
</div>
);
}
"use client";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NumberTickerProps {
value: number;
/** Digits to pad to (left). */
pad?: number;
/** Per-digit roll duration in seconds. */
duration?: number;
/** Stagger between digits. */
stagger?: number;
/** Render only after the element enters the viewport. */
startOnView?: boolean;
prefix?: string;
suffix?: string;
/** Add a small blur during digit rolls. */
blur?: boolean;
className?: string;
digitClassName?: string;
/** Insert en-US group separators (commas); fixed so server and client match. Server-component safe. */
locale?: boolean;
/** Custom formatter. Client-only — server components must use `locale` instead. */
format?: (value: number) => string;
}
const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);
export function NumberTicker({
value,
pad,
duration = 0.9,
stagger = 0.04,
startOnView = true,
prefix,
suffix,
blur = false,
className,
digitClassName,
locale,
format,
}: NumberTickerProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const inView = useInView(containerRef, { once: true, amount: 0.6 });
const [armed, setArmed] = useState(!startOnView);
useEffect(() => {
if (startOnView && inView) setArmed(true);
}, [startOnView, inView]);
const text = useMemo(() => {
const rounded = Math.round(value);
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString("en-US")
: rounded.toString();
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, format, locale]);
const glyphs = useMemo(() => {
const chars = text.split("");
// Key by place value (position from the right): a changing digit keeps its
// identity and rolls to the new value instead of remounting and replaying
// from 0. Growing numbers add glyphs on the left without re-keying the
// ones, tens, hundreds already on screen.
return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
}, [text]);
const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;
// Stagger is an entrance flourish. Once the reveal has played, value
// changes roll every digit immediately — a per-digit delay on live updates
// reads as lag.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!armed || entered) return;
const total = (duration + glyphs.length * stagger) * 1000;
const t = window.setTimeout(() => setEntered(true), total);
return () => window.clearTimeout(t);
}, [armed, entered, duration, stagger, glyphs.length]);
return (
<span
ref={containerRef}
className={cn("inline-flex items-center tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-center">
{prefix ? <span>{prefix}</span> : null}
{glyphs.map(({ char, id }, i) => {
const isDigit = /\d/.test(char);
if (!isDigit) {
return (
<span key={id} className="inline-block">
{char}
</span>
);
}
const digit = Number(char);
return (
<Digit
key={id}
digit={armed ? digit : 0}
delay={entered ? 0 : i * stagger}
duration={duration}
blur={blur}
className={digitClassName}
/>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
function Digit({
digit,
delay,
duration,
blur,
className,
}: {
digit: number;
delay: number;
duration: number;
blur: boolean;
className?: string;
}) {
const reduce = useReducedMotion();
const columnRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
return;
}
const node = columnRef.current;
const controls = animate(
node,
{ filter: ["blur(10px)", "blur(0px)"] },
{
duration: Math.min(duration * 0.75, 0.32),
delay,
ease: EASE_OUT,
},
);
return () => {
controls.stop();
node.style.filter = "blur(0px)";
};
}, [blur, delay, digit, duration, reduce]);
return (
<span
className={cn("relative inline-block overflow-hidden", className)}
style={{ height: `${DIGIT_HEIGHT_EM}em`, width: "1ch" }}
>
<motion.span
ref={columnRef}
initial={{ y: 0 }}
animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
transition={
reduce
? { duration: 0 }
: { duration, delay, ease: EASE_OUT }
}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
>
{DIGITS.map((n) => (
<span
key={n}
className="flex h-[1.1em] items-center justify-center leading-none"
>
{n}
</span>
))}
</motion.span>
</span>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ChartSeries = {
/** Key of the value in each datum. */
key: string;
label: string;
/** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */
color?: string;
};
export type ChartDatum = Record<string, string | number>;
/** "natural" is a monotone curve: smooth, but never overshoots a data point. */
export type ChartCurve = "natural" | "linear" | "step";
export type TooltipIndicator = "dot" | "line" | "dashed";
// Fixed categorical order, validated for colorblind and normal-vision
// separation (light and dark steps live in the theme's --chart-* tokens).
// Fallbacks are the light steps, for projects without the theme layer.
const SLOT_FALLBACKS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"];
/** Color for slot `index`; color follows the series, never its rank. */
export function seriesColor(series: { color?: string }, index: number) {
if (series.color) return series.color;
const slot = index % SLOT_FALLBACKS.length;
return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;
}
/**
* Layout width of the element given the returned ref. ResizeObserver reports
* the untransformed box, unlike getBoundingClientRect (react-use-measure), so a
* chart inside a scaled container (thumbnails, zoomed stages) still fills it.
*/
export function useChartWidth<T extends Element = HTMLDivElement>() {
const [el, setEl] = useState<T | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width),
);
observer.observe(el);
return () => observer.disconnect();
}, [el]);
return [setEl, width] as const;
}
// A fixed locale keeps server and client output identical for hydration.
const FULL = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
const COMPACT = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export const formatFull = (n: number) => FULL.format(n);
export const formatCompact = (n: number) => COMPACT.format(n);
/** Round tick values covering [min, max], about `count` of them. */
export function niceTicks(min: number, max: number, count = 4) {
if (min === max) max = min + 1;
const raw = (max - min) / count;
const mag = 10 ** Math.floor(Math.log10(raw));
const step =
[1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = start; v <= end + step / 2; v += step) {
ticks.push(Number(v.toFixed(10)));
}
return ticks;
}
/** Keep every k-th label so neighbours stay at least `minGap` px apart. */
export function labelStride(count: number, width: number, minGap = 56) {
return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));
}
/** Approximate gutter for axis labels at 12px. */
export function gutterFor(labels: string[]) {
return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;
}
type Point = [number, number];
/** SVG path through `points` (sorted by x) with the given curve. */
export function curvePath(points: Point[], curve: ChartCurve) {
if (points.length === 0) return "";
const [x0, y0] = points[0];
if (points.length === 1) return `M${x0},${y0}`;
if (curve === "step") {
// Changes value halfway between points, like a stepped series.
let d = `M${x0},${y0}`;
for (let i = 1; i < points.length; i++) {
const [px] = points[i - 1];
const [x, y] = points[i];
const mid = (px + x) / 2;
d += `H${mid}V${y}H${x}`;
}
return d;
}
if (curve === "linear" || points.length < 3) {
return points.map(([x, y], i) => `${i ? "L" : "M"}${x},${y}`).join("");
}
// Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays
// between its neighbours and never invents a peak the data doesn't have.
const n = points.length;
const slopes: number[] = [];
for (let i = 0; i < n - 1; i++) {
const dx = points[i + 1][0] - points[i][0];
slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);
}
const tangents = points.map((_, i) => {
if (i === 0) return slopes[0];
if (i === n - 1) return slopes[n - 2];
const a = slopes[i - 1];
const b = slopes[i];
return a * b <= 0 ? 0 : (a + b) / 2;
});
for (let i = 0; i < n - 1; i++) {
const m = slopes[i];
if (m === 0) {
tangents[i] = 0;
tangents[i + 1] = 0;
continue;
}
const a = tangents[i] / m;
const b = tangents[i + 1] / m;
const s = a * a + b * b;
if (s > 9) {
const t = 3 / Math.sqrt(s);
tangents[i] = t * a * m;
tangents[i + 1] = t * b * m;
}
}
let d = `M${x0},${y0}`;
for (let i = 0; i < n - 1; i++) {
const [xa, ya] = points[i];
const [xb, yb] = points[i + 1];
const h = (xb - xa) / 3;
d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;
}
return d;
}
// ── tooltip ────────────────────────────────────────────────────────────────
export type TooltipRow = { label: string; value: string; color: string };
function Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {
return (
<span
aria-hidden
className={cn(
"shrink-0 rounded-[2px]",
kind === "dot" && "size-2.5",
kind === "line" && "w-1 self-stretch",
kind === "dashed" && "w-0 self-stretch border-[1.5px] border-dashed",
)}
style={
kind === "dashed" ? { borderColor: color } : { background: color }
}
/>
);
}
/**
* Compact readout that springs between data positions: an optional label,
* then one row per series with a colored indicator, the name and the value.
*/
export function ChartTooltip({
open,
x,
y,
width,
label,
rows,
indicator = "dot",
}: {
open: boolean;
/** Anchor in px, relative to the chart's positioned wrapper. */
x: number;
y: number;
/** Wrapper width, to flip the tooltip before it overflows the edge. */
width: number;
label?: string;
rows: TooltipRow[];
indicator?: TooltipIndicator;
}) {
const reduce = useReducedMotion();
const flip = x > width / 2;
return (
<AnimatePresence>
{open && (
<motion.div
aria-hidden
initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}
animate={{ opacity: 1, x, y, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.12 },
scale: { duration: 0.15, ease: EASE_OUT },
// Tracks the pointer between data positions; firm enough to
// never trail behind a quick sweep.
default: { type: "spring", stiffness: 520, damping: 42 },
}
}
className="pointer-events-none absolute top-0 left-0 z-10"
>
<div
className={cn(
"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl",
flip ? "-translate-x-[calc(100%+12px)]" : "translate-x-3",
)}
>
{label ? (
<p className="font-medium text-foreground">{label}</p>
) : null}
{rows.map((row) => (
<div key={row.label} className="flex items-stretch gap-2">
<Indicator kind={indicator} color={row.color} />
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
<span className="text-muted-foreground">{row.label}</span>
<span className="font-mono font-medium tabular-nums text-foreground">
{row.value}
</span>
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
// ── legend & table ─────────────────────────────────────────────────────────
export function ChartLegend({
items,
className,
}: {
items: { label: string; color: string }[];
className?: string;
}) {
return (
<ul
className={cn(
"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground",
className,
)}
>
{items.map((item) => (
<li key={item.label} className="flex items-center gap-1.5">
<span
aria-hidden
className="size-2 shrink-0 rounded-[2px]"
style={{ background: item.color }}
/>
{item.label}
</li>
))}
</ul>
);
}
/** The table view: every value reachable without hovering or seeing color. */
export function ChartTable({
caption,
head,
rows,
}: {
caption: string;
head: string[];
rows: ReactNode[][];
}) {
// The wrapper is what hides it: a <caption> escapes a visually hidden
// table's clip and would otherwise paint over the chart.
return (
<div className="sr-only">
<table>
<caption>{caption}</caption>
<thead>
<tr>
{head.map((h) => (
<th key={h} scope="col">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.
<tr key={r}>
{row.map((cell, c) =>
c === 0 ? (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.
<th key={c} scope="row">
{cell}
</th>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.
<td key={c}>{cell}</td>
),
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Shared entrance timing: marks grow once the chart scrolls into view. */
export const GROW = { duration: 0.7, ease: EASE_OUT } as const;
/** Arrow-key stepping shared by every chart that reads positions. */
export function stepIndex(
key: string,
current: number | null,
count: number,
wrap = false,
) {
const forward = key === "ArrowRight" || key === "ArrowDown";
const back = key === "ArrowLeft" || key === "ArrowUp";
if (!forward && !back) return undefined;
if (current === null) return forward ? 0 : count - 1;
const next = current + (forward ? 1 : -1);
if (wrap) return (next + count) % count;
return Math.min(count - 1, Math.max(0, next));
}
"use client";
import { motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useMemo,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
const TabsCtx = createContext<Ctx | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
// Weighty spring for the active-tab indicator: a touch of overshoot so it
// settles with life instead of snapping.
const transition: Transition = {
type: "spring",
stiffness: 170,
damping: 24,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
children: ReactNode;
className?: string;
}) {
const [internal, setInternal] = useState(defaultValue ?? "");
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={contextValue}>
{/* layoutRoot: the indicator's layoutId measures in page coordinates, so
inside fixed/scrolled containers it would replay scroll offsets as
movement. The pill only ever travels within the list, so scoping
projection to the Tabs wrapper is always correct. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</TabsCtx.Provider>
</MotionConfig>
);
}
const listClasses: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-card p-1",
underline: "inline-flex items-center gap-1 border-b border-border",
segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5",
};
export function TabsList({ children, className }: { children: ReactNode; className?: string }) {
const { variant } = useTabs();
return (
<div role="tablist" className={cn(listClasses[variant], className)}>
{children}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
}: {
value: string;
children: ReactNode;
className?: string;
indicatorClassName?: string;
}) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
if (variant === "underline") {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
layout="position"
className={cn(
"absolute -bottom-px left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative">
{active ? (
<motion.span
layoutId={layoutId}
layout="position"
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none",
TOUCH_TARGET_CLASS,
"transition-colors",
active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// Inactive panels stay mounted but hidden, so their content (e.g. source
// code) is present in the server-rendered HTML for crawlers and assistive
// tech, instead of being dropped from the DOM.
if (!active) {
return (
<div hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
key={value}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
StatCard
labelReactNode—valuenumberHeadline value. For `format="percent"` pass a fraction (0.034 = 3.4%).
—format?"number" | "percent" | "compact" | "currency"Preset formatting. Default "number".
numbercurrency?stringISO 4217 code for `format="currency"`. Default "USD".
USDlocale?stringFormatting locale. Default "en-US" so server and client agree.
en-USformatOptions?NumberFormatOptionsExtra Intl options merged over the preset (e.g. fraction digits).
—delta?numberRelative change as a fraction (0.12 = +12%). Omit to hide the badge.
—goodDirection?"up" | "down"Which direction is good news. Default "up"; use "down" for churn, latency…
updeltaTone?"neutral" | "positive" | "negative"Force the badge tone instead of deriving it from delta and goodDirection.
—sparkline?number[]Trend values for the inline sparkline.
—sparklineColor?stringSparkline color (any CSS color). Default follows the delta tone.
—caption?ReactNodeSmall caption under the value, e.g. "vs previous 30 days".
—icon?ReactNodeOptional icon beside the label.
—loading?booleanRender a skeleton instead of the value.
falseclassName?string—StatGrid
className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated