Chart
Animated SVG charts in the shadcn style with zero chart-library dependencies: area and line charts with monotone, linear or stepped curves that reveal left to right (and again when the range changes), grouped, stacked or horizontal bars that grow from the baseline, donut and pie slices that sweep in clockwise, and radial progress rings. Every chart shares one tooltip with dot, line or dashed indicators, arrow-key reading, a legend and a screen-reader table. Colors come from the theme's --chart-1…5 tokens, validated for color-blind separation; reduced-motion safe.
Area Chart
area-chart.tsxStacked gradient areas with a time-range switch; the reveal replays whenever the range changes.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
// 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/motion/chart
import {
CartesianChart,
type CartesianChartProps,
} from "@/components/motion/chart/cartesian";
export interface AreaChartProps extends CartesianChartProps {
/** Gradient fades toward the baseline; solid keeps one translucent tone. */
fill?: "gradient" | "solid";
}
/**
* Volume over time: filled areas (overlapping or stacked) that rise from the
* baseline as their lines draw in, on scroll and whenever the range changes,
* with a snapping cursor and one tooltip for every series.
*/
export function AreaChart(props: AreaChartProps) {
return <CartesianChart type="area" {...props} />;
}
"use client";
import { motion, useInView, useReducedMotion } from "motion/react";
import { type PointerEvent, useId, useRef, useState } from "react";
import {
type ChartCurve,
type ChartDatum,
ChartLegend,
type ChartSeries,
ChartTable,
ChartTooltip,
curvePath,
formatCompact,
formatFull,
GROW,
gutterFor,
labelStride,
niceTicks,
seriesColor,
stepIndex,
type TooltipIndicator,
useChartWidth,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export interface CartesianChartProps {
data: ChartDatum[];
/** Key of the x value in each datum (dates, months…). */
index: string;
series: ChartSeries[];
/** Plot height in px. Default 250. */
height?: number;
/** Default "natural" (smooth, never overshoots a point). */
curve?: ChartCurve;
/** Stack series on top of each other instead of overlapping them. */
stacked?: boolean;
/** Draw a dot on every point. */
dots?: boolean;
/** Print each point's value above it. Best for short series. */
showValues?: boolean;
/** Show the y axis labels. Default false: gridlines carry the scale. */
yAxis?: boolean;
/** Show the legend. Default: when there are two or more series. */
legend?: boolean;
indicator?: TooltipIndicator;
/** Formats x labels and the tooltip title. */
formatIndex?: (value: string) => string;
formatValue?: (value: number) => string;
formatAxis?: (value: number) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const AXIS = 28; // x labels
const PAD_X = 12;
const PAD_TOP = 12;
/**
* Shared engine for line and area charts: monotone, linear or stepped curves,
* optional stacking and gradient fills; lines draw in left to right and areas
* rise from the baseline when scrolled into view (replayed when the range changes), a hairline cursor that
* snaps to the nearest x, and one tooltip listing every series there.
*/
export function CartesianChart({
type,
fill = "gradient",
data,
index,
series,
height = 250,
curve = "natural",
stacked = false,
dots = false,
showValues = false,
yAxis = false,
legend,
indicator = "dot",
formatIndex = (v) => v,
formatValue = formatFull,
formatAxis = formatCompact,
title,
className,
}: CartesianChartProps & {
type: "line" | "area";
fill?: "gradient" | "solid";
}) {
const uid = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const [measureRef, width] = useChartWidth();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const shown = reduce || inView;
const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;
// Per-series [lower, upper] for every point; lower is 0 unless stacked.
const bands = series.map((_, si) =>
data.map((d) => {
const lower = stacked
? series.slice(0, si).reduce((sum, s) => sum + value(d, s.key), 0)
: 0;
return [lower, lower + value(d, series[si].key)] as const;
}),
);
const tops = bands.flatMap((b) => b.map(([, up]) => up));
const ticks = niceTicks(Math.min(0, ...tops), Math.max(...tops, 0));
const lo = ticks[0];
const hi = ticks[ticks.length - 1];
const left = yAxis ? gutterFor(ticks.map(formatAxis)) : PAD_X;
const plotW = Math.max(0, width - left - PAD_X);
const step = data.length > 1 ? plotW / (data.length - 1) : 0;
const x = (i: number) => left + i * step;
const y = (v: number) => PAD_TOP + (1 - (v - lo) / (hi - lo)) * (height - PAD_TOP);
const stride = labelStride(data.length, plotW, 64);
const last = data.length - 1;
const showLegend = legend ?? series.length > 1;
const linePts = (si: number) =>
bands[si].map(([, up], i) => [x(i), y(up)] as [number, number]);
const areaPath = (si: number) => {
const top = curvePath(linePts(si), curve);
const bottom = curvePath(
bands[si].map(([low], i) => [x(i), y(low)] as [number, number]).reverse(),
curve,
).replace(/^M/, "L");
return `${top}${bottom}Z`;
};
// Replays the reveal when the visible range changes (e.g. 7d → 90d).
const revealKey = `${data.length}-${String(data[0]?.[index] ?? "")}`;
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const i = Math.round((e.clientX - box.left - left) / (step || 1));
setActive(Math.min(last, Math.max(0, i)));
};
const activeTop =
active === null
? 0
: Math.min(...series.map((_, si) => y(bands[si][active][1])));
return (
<div ref={viewRef} className={cn("w-full", className)}>
<div ref={measureRef} className="relative w-full">
{width > 0 && data.length > 0 && (
// biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot
<svg
width={width}
height={height + AXIS}
role="group"
aria-label={`${title}. Use arrow keys to read each point.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={(e) => {
const next = stepIndex(e.key, active, data.length);
if (next === undefined) return;
e.preventDefault();
setActive(next);
}}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
onBlur={() => setActive(null)}
className="block touch-pan-y overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<defs>
{type === "area" &&
series.map((s, si) => (
<linearGradient
key={s.key}
id={`${uid}-fill-${si}`}
x1="0"
x2="0"
y1="0"
y2="1"
>
<stop
offset="5%"
stopColor={seriesColor(s, si)}
stopOpacity={fill === "gradient" ? 0.6 : 0.4}
/>
<stop
offset="95%"
stopColor={seriesColor(s, si)}
stopOpacity={fill === "gradient" ? 0.05 : 0.4}
/>
</linearGradient>
))}
</defs>
{ticks.map((t) => (
<g key={t}>
<line
x1={yAxis ? left : 0}
x2={width}
y1={y(t)}
y2={y(t)}
stroke="var(--border-strong)"
shapeRendering="crispEdges"
/>
{yAxis && (
<text
x={left - 8}
y={y(t)}
dy="0.32em"
textAnchor="end"
className="fill-muted-foreground text-xs tabular-nums"
>
{formatAxis(t)}
</text>
)}
</g>
))}
{data.map((d, i) =>
// Every stride-th label plus the last, minus a strided one that
// would crowd the last.
i === last || (i % stride === 0 && (last - i) * step >= 64) ? (
<text
// biome-ignore lint/suspicious/noArrayIndexKey: x positions keep their order.
key={i}
x={x(i)}
y={height + 20}
textAnchor={i === 0 ? "start" : i === last ? "end" : "middle"}
className="fill-muted-foreground text-xs"
>
{formatIndex(String(d[index]))}
</text>
) : null,
)}
{active !== null && (
<line
x1={x(active)}
x2={x(active)}
y1={PAD_TOP}
y2={height}
stroke="var(--muted-foreground)"
strokeOpacity={0.4}
shapeRendering="crispEdges"
/>
)}
{/* Remounted per range so the entrance replays when the data
window changes (e.g. 7 days → 3 months). */}
<g key={revealKey}>
{type === "area" &&
series.map((s, si) => (
<motion.path
key={s.key}
d={areaPath(si)}
fill={`url(#${uid}-fill-${si})`}
initial={reduce ? false : { opacity: 0, scaleY: 0.4 }}
animate={shown ? { opacity: 1, scaleY: 1 } : { opacity: 0, scaleY: 0.4 }}
transition={reduce ? { duration: 0 } : { ...GROW, duration: 0.9, delay: 0.15 }}
style={{ transformBox: "view-box", transformOrigin: `0px ${y(lo)}px` }}
/>
))}
{series.map((s, si) => (
<motion.path
key={s.key}
d={curvePath(linePts(si), curve)}
fill="none"
stroke={seriesColor(s, si)}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 1.1, ease: GROW.ease, delay: si * 0.08 }}
/>
))}
{(dots || showValues) && (
<motion.g
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.3, delay: 0.9 }}
>
{dots &&
series.map((s, si) =>
linePts(si).map(([cx, cy], i) => (
<circle
// biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.
key={`${s.key}-${i}`}
cx={cx}
cy={cy}
r={3.5}
fill={seriesColor(s, si)}
stroke="var(--card)"
strokeWidth={2}
/>
)),
)}
{showValues &&
series.map((s, si) =>
linePts(si).map(([cx, cy], i) => (
<text
// biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.
key={`${s.key}-v${i}`}
x={cx}
y={cy - 12}
textAnchor="middle"
className="fill-foreground text-xs font-medium tabular-nums"
>
{formatValue(value(data[i], s.key))}
</text>
)),
)}
</motion.g>
)}
</g>
{active !== null &&
series.map((s, si) => (
<motion.circle
key={s.key}
initial={reduce ? false : { scale: 0.4 }}
animate={{ scale: 1, cx: x(active), cy: y(bands[si][active][1]) }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40 }
}
r={5}
fill={seriesColor(s, si)}
stroke="var(--card)"
strokeWidth={2}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
/>
))}
</svg>
)}
<ChartTooltip
open={active !== null}
x={active === null ? 0 : x(active)}
y={activeTop}
width={width}
indicator={indicator}
label={active === null ? "" : formatIndex(String(data[active][index]))}
rows={
active === null
? []
: (() => {
const rows = series.map((s, si) => ({
label: s.label,
value: formatValue(value(data[active], s.key)),
color: seriesColor(s, si),
}));
// Stacked: read top-down, the way the bands sit.
return stacked ? rows.reverse() : rows;
})()
}
/>
</div>
{showLegend && (
<ChartLegend
items={series.map((s, si) => ({ label: s.label, color: seriesColor(s, si) }))}
/>
)}
<ChartTable
caption={title}
head={[index, ...series.map((s) => s.label)]}
rows={data.map((d) => [
formatIndex(String(d[index])),
...series.map((s) => formatValue(value(d, s.key))),
])}
/>
</div>
);
}
"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
fill?"solid" | "gradient"Gradient fades toward the baseline; solid keeps one translucent tone.
—dataChartDatum[]—indexstringKey of the x value in each datum (dates, months…).
—seriesChartSeries[]—height?numberPlot height in px. Default 250.
—curve?"step" | "natural" | "linear"Default "natural" (smooth, never overshoots a point).
—stacked?booleanStack series on top of each other instead of overlapping them.
—dots?booleanDraw a dot on every point.
—showValues?booleanPrint each point's value above it. Best for short series.
—yAxis?booleanShow the y axis labels. Default false: gridlines carry the scale.
—legend?booleanShow the legend. Default: when there are two or more series.
—indicator?"dashed" | "line" | "dot"—formatIndex?((value: string) => string)Formats x labels and the tooltip title.
—formatValue?((value: number) => string)—formatAxis?((value: number) => string)—titlestringNames the chart for assistive tech and the table view.
—className?string—Bar Chart
bar-chart.tsxGrouped columns that grow from the baseline; hovering a category dims the rest and reads every series.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
import { motion, useInView, useReducedMotion } from "motion/react";
import { useRef, useState } from "react";
import {
type ChartDatum,
ChartLegend,
type ChartSeries,
ChartTable,
ChartTooltip,
formatCompact,
formatFull,
GROW,
gutterFor,
labelStride,
niceTicks,
seriesColor,
stepIndex,
type TooltipIndicator,
useChartWidth,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export interface BarChartProps {
data: ChartDatum[];
/** Key of the category label in each datum. */
index: string;
series: ChartSeries[];
/** "vertical" draws columns; "horizontal" draws rows of bars. */
layout?: "vertical" | "horizontal";
/** Stack series into one bar per category. */
stacked?: boolean;
/** Print each bar's value at its end. */
showValues?: boolean;
/** Corner radius of the bar's data end, in px. Default 6. */
radius?: number;
/** Plot height in px (vertical layout). Default 250. */
height?: number;
/** Show the value axis labels. Default false: gridlines carry the scale. */
valueAxis?: boolean;
/** Show the legend. Default: when there are two or more series. */
legend?: boolean;
indicator?: TooltipIndicator;
formatIndex?: (value: string) => string;
formatValue?: (value: number) => string;
formatAxis?: (value: number) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const GAP = 4; // between bars of one group
const ROW = 36; // horizontal layout: px per category
const AXIS = 28;
type Rect = { x: number; y: number; w: number; h: number };
/** Bar path rounded only at the data end, square against the baseline. */
function barPath({ x, y, w, h }: Rect, r: number, end: "top" | "bottom" | "right" | "left") {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
if (end === "top")
return `M${x},${y + h}V${y + rr}Q${x},${y} ${x + rr},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + h}Z`;
if (end === "bottom")
return `M${x},${y}V${y + h - rr}Q${x},${y + h} ${x + rr},${y + h}H${x + w - rr}Q${x + w},${y + h} ${x + w},${y + h - rr}V${y}Z`;
if (end === "right")
return `M${x},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + h - rr}Q${x + w},${y + h} ${x + w - rr},${y + h}H${x}Z`;
return `M${x + w},${y}H${x + rr}Q${x},${y} ${x},${y + rr}V${y + h - rr}Q${x},${y + h} ${x + rr},${y + h}H${x + w}Z`;
}
/**
* Compares magnitudes across categories as columns or horizontal bars,
* grouped or stacked. Bars grow from the baseline when scrolled into view;
* hovering or arrowing to a category dims the others and shows one tooltip
* for every series.
*/
export function BarChart({
data,
index,
series,
layout = "vertical",
stacked = false,
showValues = false,
radius = 6,
height: heightProp = 250,
valueAxis = false,
legend,
indicator = "dot",
formatIndex = (v) => v,
formatValue = formatFull,
formatAxis = formatCompact,
title,
className,
}: BarChartProps) {
const reduce = useReducedMotion();
const [measureRef, width] = useChartWidth();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const shown = reduce || inView;
const horizontal = layout === "horizontal";
const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;
// Value extent: stacked bars sum positives and negatives separately.
const extents = data.flatMap((d) => {
if (!stacked) return series.map((s) => value(d, s.key));
let pos = 0;
let neg = 0;
for (const s of series) {
const v = value(d, s.key);
if (v >= 0) pos += v;
else neg += v;
}
return [pos, neg];
});
const ticks = niceTicks(Math.min(0, ...extents), Math.max(0, ...extents));
const lo = ticks[0];
const hi = ticks[ticks.length - 1];
const labelGutter = horizontal
? gutterFor(data.map((d) => formatIndex(String(d[index]))))
: 0;
const valueGutter = !horizontal && valueAxis ? gutterFor(ticks.map(formatAxis)) : 0;
// Room at the data end for printed values.
const endRoom = showValues ? (horizontal ? 44 : 20) : 0;
const height = horizontal ? data.length * ROW : heightProp;
// Category axis runs along x (vertical) or y (horizontal).
const catStart = horizontal ? 0 : valueGutter;
const catLength = horizontal ? height : Math.max(0, width - valueGutter);
const band = data.length ? catLength / data.length : 0;
const valStart = horizontal ? labelGutter : endRoom;
const valLength = horizontal
? Math.max(0, width - labelGutter - endRoom)
: height - endRoom;
// Value → position along the value axis, measured from its origin.
const scale = (v: number) => ((v - lo) / (hi - lo)) * valLength;
const zero = scale(0);
const groups = stacked ? 1 : series.length;
const thickness = Math.max(
3,
Math.min(horizontal ? 24 : 56, (band * (horizontal ? 0.72 : 0.62) - GAP * (groups - 1)) / groups),
);
const groupLen = thickness * groups + GAP * (groups - 1);
const bars = data.map((d, i) => {
const offset = catStart + band * i + (band - groupLen) / 2;
let pos = 0;
let neg = 0;
return series.map((s, si) => {
const v = value(d, s.key);
const from = stacked ? (v >= 0 ? pos : neg) : 0;
const to = from + v;
if (stacked) {
if (v >= 0) pos = to;
else neg = to;
}
const a = scale(Math.min(from, to));
const b = scale(Math.max(from, to));
const lane = offset + (stacked ? 0 : si * (thickness + GAP));
const rect: Rect = horizontal
? { x: valStart + a, y: lane, w: b - a, h: thickness }
: { x: lane, y: valStart + (valLength - b), w: thickness, h: b - a };
// Only the outermost segment of a stack gets the rounded end.
const outer =
!stacked ||
series.slice(si + 1).every((t) => Math.sign(value(d, t.key)) !== Math.sign(v) || value(d, t.key) === 0);
const end = horizontal ? (v >= 0 ? "right" : "left") : v >= 0 ? "top" : "bottom";
return { s, si, v, rect, outer, end } as const;
});
});
const totalH = horizontal ? height : height + AXIS;
const stride = horizontal ? 1 : labelStride(data.length, catLength, 48);
// Tooltip anchors at the category's data end.
const anchor = (i: number) => {
const rs = bars[i].map((b) => b.rect);
if (horizontal) {
return {
x: Math.max(...rs.map((r) => r.x + r.w)),
y: catStart + band * i + band / 2,
};
}
return {
x: catStart + band * i + band / 2,
y: Math.min(...rs.map((r) => r.y)) + 12,
};
};
const tip = active === null ? { x: 0, y: 0 } : anchor(active);
return (
<div ref={viewRef} className={cn("w-full", className)}>
<div ref={measureRef} className="relative w-full">
{width > 0 && data.length > 0 && (
// biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot
<svg
width={width}
height={totalH}
role="group"
aria-label={`${title}. Use arrow keys to read each category.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={(e) => {
const next = stepIndex(e.key, active, data.length);
if (next === undefined) return;
e.preventDefault();
setActive(next);
}}
onPointerLeave={() => setActive(null)}
onBlur={() => setActive(null)}
className="block overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{/* Gridlines run across the value axis only. */}
{ticks.map((t) => {
const p = valStart + (horizontal ? scale(t) : valLength - scale(t));
return (
<g key={t}>
{horizontal ? (
<line x1={p} x2={p} y1={0} y2={height} stroke="var(--border-strong)" shapeRendering="crispEdges" />
) : (
<line x1={valueGutter} x2={width} y1={p} y2={p} stroke="var(--border-strong)" shapeRendering="crispEdges" />
)}
{valueAxis && !horizontal && (
<text x={valueGutter - 8} y={p} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-xs tabular-nums">
{formatAxis(t)}
</text>
)}
</g>
);
})}
{bars.map((group, i) => {
const dim = active !== null && active !== i;
const label = formatIndex(String(data[i][index]));
return (
// biome-ignore lint/suspicious/noArrayIndexKey: categories keep their order.
<g key={i}>
{group.map(({ s, si, rect, outer, end }) => (
<motion.path
key={s.key}
d={barPath(rect, outer ? radius : 0, end)}
fill={seriesColor(s, si)}
initial={false}
animate={{
[horizontal ? "scaleX" : "scaleY"]: shown ? 1 : 0,
opacity: dim ? 0.3 : 1,
}}
transition={{
scaleX: reduce ? { duration: 0 } : { ...GROW, delay: i * 0.04 },
scaleY: reduce ? { duration: 0 } : { ...GROW, delay: i * 0.04 },
opacity: { duration: 0.15 },
}}
style={{
// Grow from the zero line, whichever side the bar sits on.
transformBox: "view-box",
transformOrigin: horizontal
? `${valStart + zero}px 0px`
: `0px ${valStart + valLength - zero}px`,
}}
/>
))}
{showValues &&
(stacked ? [group[group.length - 1]] : group).map(({ s, rect, v }) => {
const shownValue = stacked
? group.reduce((sum, g) => sum + g.v, 0)
: v;
const end = stacked
? horizontal
? Math.max(...group.map((g) => g.rect.x + g.rect.w))
: Math.min(...group.map((g) => g.rect.y))
: horizontal
? rect.x + rect.w
: rect.y;
return (
<motion.text
key={`${s.key}-v`}
x={horizontal ? end + 8 : rect.x + (stacked ? groupLen : rect.w) / 2}
y={horizontal ? rect.y + (stacked ? groupLen : rect.h) / 2 : end - 8}
dy={horizontal ? "0.32em" : undefined}
textAnchor={horizontal ? "start" : "middle"}
className="fill-foreground text-xs font-medium tabular-nums"
initial={false}
animate={{ opacity: shown ? (dim ? 0.3 : 1) : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.3, delay: shown && active === null ? 0.5 + i * 0.04 : 0 }}
>
{formatValue(shownValue)}
</motion.text>
);
})}
{horizontal ? (
<text
x={labelGutter - 10}
y={catStart + band * i + band / 2}
dy="0.32em"
textAnchor="end"
className="fill-muted-foreground text-xs"
>
{label}
</text>
) : (
i % stride === 0 && (
<text
x={catStart + band * i + band / 2}
y={height + 20}
textAnchor="middle"
className="fill-muted-foreground text-xs"
>
{label}
</text>
)
)}
{/* The whole category band is the hit target, not the bar. */}
<rect
x={horizontal ? 0 : catStart + band * i}
y={horizontal ? catStart + band * i : 0}
width={horizontal ? width : band}
height={horizontal ? band : height}
fill="transparent"
onPointerEnter={() => setActive(i)}
/>
</g>
);
})}
</svg>
)}
<ChartTooltip
open={active !== null}
x={tip.x}
y={tip.y}
width={width}
indicator={indicator}
label={active === null ? "" : formatIndex(String(data[active][index]))}
rows={
active === null
? []
: series.map((s, si) => ({
label: s.label,
value: formatValue(value(data[active], s.key)),
color: seriesColor(s, si),
}))
}
/>
</div>
{(legend ?? series.length > 1) && (
<ChartLegend
items={series.map((s, si) => ({ label: s.label, color: seriesColor(s, si) }))}
/>
)}
<ChartTable
caption={title}
head={[index, ...series.map((s) => s.label)]}
rows={data.map((d) => [
formatIndex(String(d[index])),
...series.map((s) => formatValue(value(d, s.key))),
])}
/>
</div>
);
}
"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));
}
API Reference
dataChartDatum[]—indexstringKey of the category label in each datum.
—seriesChartSeries[]—layout?"horizontal" | "vertical""vertical" draws columns; "horizontal" draws rows of bars.
verticalstacked?booleanStack series into one bar per category.
falseshowValues?booleanPrint each bar's value at its end.
falseradius?numberCorner radius of the bar's data end, in px. Default 6.
6height?numberPlot height in px (vertical layout). Default 250.
250valueAxis?booleanShow the value axis labels. Default false: gridlines carry the scale.
falselegend?booleanShow the legend. Default: when there are two or more series.
—indicator?"dashed" | "line" | "dot"dotformatIndex?((value: string) => string)(v) => vformatValue?((value: number) => string)(n: number) => FULL.format(n)formatAxis?((value: number) => string)(n: number) => COMPACT.format(n)titlestringNames the chart for assistive tech and the table view.
—className?string—Horizontal Bars
bar-chart.tsxThe same BarChart with layout="horizontal" and printed values: ranked categories read left to right.
API Reference
dataChartDatum[]—indexstringKey of the category label in each datum.
—seriesChartSeries[]—layout?"horizontal" | "vertical""vertical" draws columns; "horizontal" draws rows of bars.
verticalstacked?booleanStack series into one bar per category.
falseshowValues?booleanPrint each bar's value at its end.
falseradius?numberCorner radius of the bar's data end, in px. Default 6.
6height?numberPlot height in px (vertical layout). Default 250.
250valueAxis?booleanShow the value axis labels. Default false: gridlines carry the scale.
falselegend?booleanShow the legend. Default: when there are two or more series.
—indicator?"dashed" | "line" | "dot"dotformatIndex?((value: string) => string)(v) => vformatValue?((value: number) => string)(n: number) => FULL.format(n)formatAxis?((value: number) => string)(n: number) => COMPACT.format(n)titlestringNames the chart for assistive tech and the table view.
—className?string—Line Chart
line-chart.tsxSmooth monotone lines with dots, a snapping cursor and one tooltip for every series.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
import {
CartesianChart,
type CartesianChartProps,
} from "@/components/motion/chart/cartesian";
export type LineChartProps = CartesianChartProps;
/**
* Trend over time: 2px lines (smooth, straight or stepped) revealed left to
* right when scrolled into view, a cursor that snaps to the nearest point and
* one tooltip listing every series. Arrow keys step through points.
*/
export function LineChart(props: LineChartProps) {
return <CartesianChart type="line" {...props} />;
}
"use client";
import { motion, useInView, useReducedMotion } from "motion/react";
import { type PointerEvent, useId, useRef, useState } from "react";
import {
type ChartCurve,
type ChartDatum,
ChartLegend,
type ChartSeries,
ChartTable,
ChartTooltip,
curvePath,
formatCompact,
formatFull,
GROW,
gutterFor,
labelStride,
niceTicks,
seriesColor,
stepIndex,
type TooltipIndicator,
useChartWidth,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export interface CartesianChartProps {
data: ChartDatum[];
/** Key of the x value in each datum (dates, months…). */
index: string;
series: ChartSeries[];
/** Plot height in px. Default 250. */
height?: number;
/** Default "natural" (smooth, never overshoots a point). */
curve?: ChartCurve;
/** Stack series on top of each other instead of overlapping them. */
stacked?: boolean;
/** Draw a dot on every point. */
dots?: boolean;
/** Print each point's value above it. Best for short series. */
showValues?: boolean;
/** Show the y axis labels. Default false: gridlines carry the scale. */
yAxis?: boolean;
/** Show the legend. Default: when there are two or more series. */
legend?: boolean;
indicator?: TooltipIndicator;
/** Formats x labels and the tooltip title. */
formatIndex?: (value: string) => string;
formatValue?: (value: number) => string;
formatAxis?: (value: number) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const AXIS = 28; // x labels
const PAD_X = 12;
const PAD_TOP = 12;
/**
* Shared engine for line and area charts: monotone, linear or stepped curves,
* optional stacking and gradient fills; lines draw in left to right and areas
* rise from the baseline when scrolled into view (replayed when the range changes), a hairline cursor that
* snaps to the nearest x, and one tooltip listing every series there.
*/
export function CartesianChart({
type,
fill = "gradient",
data,
index,
series,
height = 250,
curve = "natural",
stacked = false,
dots = false,
showValues = false,
yAxis = false,
legend,
indicator = "dot",
formatIndex = (v) => v,
formatValue = formatFull,
formatAxis = formatCompact,
title,
className,
}: CartesianChartProps & {
type: "line" | "area";
fill?: "gradient" | "solid";
}) {
const uid = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const [measureRef, width] = useChartWidth();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const shown = reduce || inView;
const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;
// Per-series [lower, upper] for every point; lower is 0 unless stacked.
const bands = series.map((_, si) =>
data.map((d) => {
const lower = stacked
? series.slice(0, si).reduce((sum, s) => sum + value(d, s.key), 0)
: 0;
return [lower, lower + value(d, series[si].key)] as const;
}),
);
const tops = bands.flatMap((b) => b.map(([, up]) => up));
const ticks = niceTicks(Math.min(0, ...tops), Math.max(...tops, 0));
const lo = ticks[0];
const hi = ticks[ticks.length - 1];
const left = yAxis ? gutterFor(ticks.map(formatAxis)) : PAD_X;
const plotW = Math.max(0, width - left - PAD_X);
const step = data.length > 1 ? plotW / (data.length - 1) : 0;
const x = (i: number) => left + i * step;
const y = (v: number) => PAD_TOP + (1 - (v - lo) / (hi - lo)) * (height - PAD_TOP);
const stride = labelStride(data.length, plotW, 64);
const last = data.length - 1;
const showLegend = legend ?? series.length > 1;
const linePts = (si: number) =>
bands[si].map(([, up], i) => [x(i), y(up)] as [number, number]);
const areaPath = (si: number) => {
const top = curvePath(linePts(si), curve);
const bottom = curvePath(
bands[si].map(([low], i) => [x(i), y(low)] as [number, number]).reverse(),
curve,
).replace(/^M/, "L");
return `${top}${bottom}Z`;
};
// Replays the reveal when the visible range changes (e.g. 7d → 90d).
const revealKey = `${data.length}-${String(data[0]?.[index] ?? "")}`;
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const i = Math.round((e.clientX - box.left - left) / (step || 1));
setActive(Math.min(last, Math.max(0, i)));
};
const activeTop =
active === null
? 0
: Math.min(...series.map((_, si) => y(bands[si][active][1])));
return (
<div ref={viewRef} className={cn("w-full", className)}>
<div ref={measureRef} className="relative w-full">
{width > 0 && data.length > 0 && (
// biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot
<svg
width={width}
height={height + AXIS}
role="group"
aria-label={`${title}. Use arrow keys to read each point.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={(e) => {
const next = stepIndex(e.key, active, data.length);
if (next === undefined) return;
e.preventDefault();
setActive(next);
}}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
onBlur={() => setActive(null)}
className="block touch-pan-y overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<defs>
{type === "area" &&
series.map((s, si) => (
<linearGradient
key={s.key}
id={`${uid}-fill-${si}`}
x1="0"
x2="0"
y1="0"
y2="1"
>
<stop
offset="5%"
stopColor={seriesColor(s, si)}
stopOpacity={fill === "gradient" ? 0.6 : 0.4}
/>
<stop
offset="95%"
stopColor={seriesColor(s, si)}
stopOpacity={fill === "gradient" ? 0.05 : 0.4}
/>
</linearGradient>
))}
</defs>
{ticks.map((t) => (
<g key={t}>
<line
x1={yAxis ? left : 0}
x2={width}
y1={y(t)}
y2={y(t)}
stroke="var(--border-strong)"
shapeRendering="crispEdges"
/>
{yAxis && (
<text
x={left - 8}
y={y(t)}
dy="0.32em"
textAnchor="end"
className="fill-muted-foreground text-xs tabular-nums"
>
{formatAxis(t)}
</text>
)}
</g>
))}
{data.map((d, i) =>
// Every stride-th label plus the last, minus a strided one that
// would crowd the last.
i === last || (i % stride === 0 && (last - i) * step >= 64) ? (
<text
// biome-ignore lint/suspicious/noArrayIndexKey: x positions keep their order.
key={i}
x={x(i)}
y={height + 20}
textAnchor={i === 0 ? "start" : i === last ? "end" : "middle"}
className="fill-muted-foreground text-xs"
>
{formatIndex(String(d[index]))}
</text>
) : null,
)}
{active !== null && (
<line
x1={x(active)}
x2={x(active)}
y1={PAD_TOP}
y2={height}
stroke="var(--muted-foreground)"
strokeOpacity={0.4}
shapeRendering="crispEdges"
/>
)}
{/* Remounted per range so the entrance replays when the data
window changes (e.g. 7 days → 3 months). */}
<g key={revealKey}>
{type === "area" &&
series.map((s, si) => (
<motion.path
key={s.key}
d={areaPath(si)}
fill={`url(#${uid}-fill-${si})`}
initial={reduce ? false : { opacity: 0, scaleY: 0.4 }}
animate={shown ? { opacity: 1, scaleY: 1 } : { opacity: 0, scaleY: 0.4 }}
transition={reduce ? { duration: 0 } : { ...GROW, duration: 0.9, delay: 0.15 }}
style={{ transformBox: "view-box", transformOrigin: `0px ${y(lo)}px` }}
/>
))}
{series.map((s, si) => (
<motion.path
key={s.key}
d={curvePath(linePts(si), curve)}
fill="none"
stroke={seriesColor(s, si)}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 1.1, ease: GROW.ease, delay: si * 0.08 }}
/>
))}
{(dots || showValues) && (
<motion.g
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.3, delay: 0.9 }}
>
{dots &&
series.map((s, si) =>
linePts(si).map(([cx, cy], i) => (
<circle
// biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.
key={`${s.key}-${i}`}
cx={cx}
cy={cy}
r={3.5}
fill={seriesColor(s, si)}
stroke="var(--card)"
strokeWidth={2}
/>
)),
)}
{showValues &&
series.map((s, si) =>
linePts(si).map(([cx, cy], i) => (
<text
// biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.
key={`${s.key}-v${i}`}
x={cx}
y={cy - 12}
textAnchor="middle"
className="fill-foreground text-xs font-medium tabular-nums"
>
{formatValue(value(data[i], s.key))}
</text>
)),
)}
</motion.g>
)}
</g>
{active !== null &&
series.map((s, si) => (
<motion.circle
key={s.key}
initial={reduce ? false : { scale: 0.4 }}
animate={{ scale: 1, cx: x(active), cy: y(bands[si][active][1]) }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40 }
}
r={5}
fill={seriesColor(s, si)}
stroke="var(--card)"
strokeWidth={2}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
/>
))}
</svg>
)}
<ChartTooltip
open={active !== null}
x={active === null ? 0 : x(active)}
y={activeTop}
width={width}
indicator={indicator}
label={active === null ? "" : formatIndex(String(data[active][index]))}
rows={
active === null
? []
: (() => {
const rows = series.map((s, si) => ({
label: s.label,
value: formatValue(value(data[active], s.key)),
color: seriesColor(s, si),
}));
// Stacked: read top-down, the way the bands sit.
return stacked ? rows.reverse() : rows;
})()
}
/>
</div>
{showLegend && (
<ChartLegend
items={series.map((s, si) => ({ label: s.label, color: seriesColor(s, si) }))}
/>
)}
<ChartTable
caption={title}
head={[index, ...series.map((s) => s.label)]}
rows={data.map((d) => [
formatIndex(String(d[index])),
...series.map((s) => formatValue(value(d, s.key))),
])}
/>
</div>
);
}
"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));
}
API Reference
dataChartDatum[]—indexstringKey of the x value in each datum (dates, months…).
—seriesChartSeries[]—height?numberPlot height in px. Default 250.
—curve?"step" | "natural" | "linear"Default "natural" (smooth, never overshoots a point).
—stacked?booleanStack series on top of each other instead of overlapping them.
—dots?booleanDraw a dot on every point.
—showValues?booleanPrint each point's value above it. Best for short series.
—yAxis?booleanShow the y axis labels. Default false: gridlines carry the scale.
—legend?booleanShow the legend. Default: when there are two or more series.
—indicator?"dashed" | "line" | "dot"—formatIndex?((value: string) => string)Formats x labels and the tooltip title.
—formatValue?((value: number) => string)—formatAxis?((value: number) => string)—titlestringNames the chart for assistive tech and the table view.
—className?string—Donut Chart
donut-chart.tsxSlices sweep in clockwise; the hovered slice lifts and the center swaps from the total to its value and share. variant="pie" for a solid pie.
Install
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))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { useId, useRef, useState } from "react";
import {
ChartLegend,
ChartTable,
formatFull,
seriesColor,
stepIndex,
} from "@/components/motion/chart/shared";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DonutSlice = {
label: string;
value: number;
/** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */
color?: string;
};
export interface DonutChartProps {
/**
* Parts of one whole. Keep it to five or fewer and fold the long tail into
* an "Other" slice; close values compare better as bars.
*/
data: DonutSlice[];
/** "donut" (default) leaves room for the center readout; "pie" is solid. */
variant?: "donut" | "pie";
/** Diameter in px. Default 220. */
size?: number;
/** Ring thickness for the donut, in px. Default 32% of the radius. */
thickness?: number;
/** Center caption under the total. Default "Total". */
totalLabel?: string;
/** Show the legend under the chart. Default true. */
legend?: boolean;
formatValue?: (value: number) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const ACTIVE_GROW = 1.06;
/** Annular sector from angle a0 to a1 (radians, 0 = 12 o'clock, clockwise). */
function sector(c: number, r0: number, r1: number, a0: number, a1: number): string {
const pt = (r: number, a: number) =>
`${c + r * Math.sin(a)},${c - r * Math.cos(a)}`;
const large = a1 - a0 > Math.PI ? 1 : 0;
if (a1 - a0 >= Math.PI * 2 - 1e-6) {
// A full ring can't be one arc; split it in two halves.
const mid = a0 + Math.PI;
return `${sector(c, r0, r1, a0, mid)}${sector(c, r0, r1, mid, a1)}`;
}
const outer = `M${pt(r1, a0)}A${r1},${r1} 0 ${large} 1 ${pt(r1, a1)}`;
if (r0 <= 0) return `${outer}L${c},${c}Z`;
return `${outer}L${pt(r0, a1)}A${r0},${r0} 0 ${large} 0 ${pt(r0, a0)}Z`;
}
/**
* Share of a whole as a donut or pie. Slices sweep in clockwise when scrolled
* into view; hovering or arrowing to a slice lifts it, dims the rest and
* swaps the center readout from the total to that slice's value and share.
*/
export function DonutChart({
data,
variant = "donut",
size = 220,
thickness,
totalLabel = "Total",
legend = true,
formatValue = formatFull,
title,
className,
}: DonutChartProps) {
const uid = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const shown = reduce || inView;
// Leave room inside the box for the active slice to grow.
const c = size / 2;
const r1 = (size / 2) / ACTIVE_GROW - 2;
const r0 = variant === "pie" ? 0 : r1 - (thickness ?? r1 * 0.32);
const total = data.reduce((sum, d) => sum + Math.max(0, d.value), 0);
const pct = (v: number) =>
total ? `${Math.round((Math.max(0, v) / total) * 100)}%` : "0%";
let cursor = 0;
const slices = data.map((d, i) => {
const a0 = cursor;
cursor += total ? (Math.max(0, d.value) / total) * Math.PI * 2 : 0;
return { ...d, color: seriesColor(d, i), path: sector(c, r0, r1, a0, cursor) };
});
const center =
active === null
? { key: "total", value: formatValue(total), label: totalLabel }
: {
key: `s${active}`,
value: formatValue(data[active].value),
label: `${data[active].label} · ${pct(data[active].value)}`,
};
// The reveal mask: one stroke as thick as the whole radius, swept from 12
// o'clock with pathLength so every slice uncovers in clockwise order.
const maskR = (r1 + r0) / 2;
const maskW = r1 - r0 + 8;
// Donut: the readout sits in the hole. Pie: it sits under the chart.
const readout = (
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={center.key}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
y: -6,
filter: "blur(4px)",
transition: { duration: 0.12, ease: EASE_OUT },
}
}
transition={{ duration: 0.2, ease: EASE_OUT }}
className="flex flex-col items-center text-center"
>
<span className="text-3xl font-bold tracking-tight text-foreground tabular-nums">
{center.value}
</span>
<span className="truncate text-xs text-muted-foreground">
{center.label}
</span>
</motion.div>
</AnimatePresence>
);
return (
<div ref={viewRef} className={cn("flex flex-col items-center", className)}>
{/* biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the ring */}
<div
role="group"
aria-label={`${title}. Use arrow keys to read each slice.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={(e) => {
const next = stepIndex(e.key, active, data.length, true);
if (next === undefined) return;
e.preventDefault();
setActive(next);
}}
onBlur={() => setActive(null)}
className="relative rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
aria-hidden
onPointerLeave={() => setActive(null)}
className="overflow-visible"
>
<title>{title}</title>
<defs>
<mask id={`${uid}-sweep`}>
<motion.circle
cx={c}
cy={c}
r={maskR}
fill="none"
stroke="white"
strokeWidth={maskW}
transform={`rotate(-90 ${c} ${c})`}
initial={false}
animate={{ pathLength: shown ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 1, ease: EASE_OUT }}
/>
</mask>
</defs>
<g mask={`url(#${uid}-sweep)`}>
{slices.map((s, i) => (
<motion.path
key={s.label}
d={s.path}
fill={s.color}
// A surface-colored stroke separates neighbouring slices.
stroke="var(--card)"
strokeWidth={2}
strokeLinejoin="round"
initial={false}
animate={{
scale: active === i && !reduce ? ACTIVE_GROW : 1,
opacity: active !== null && active !== i ? 0.35 : 1,
}}
transition={{
scale: { type: "spring", stiffness: 420, damping: 28 },
opacity: { duration: 0.15 },
}}
style={{
transformBox: "view-box",
transformOrigin: `${c}px ${c}px`,
}}
onPointerEnter={() => setActive(i)}
/>
))}
</g>
</svg>
{variant === "donut" && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{readout}
</div>
)}
</div>
{variant === "pie" && (
<div className="mt-3 flex min-h-12 items-center justify-center">{readout}</div>
)}
{legend && (
<ChartLegend
items={slices.map((s) => ({ label: s.label, color: s.color }))}
/>
)}
<ChartTable
caption={title}
head={["Slice", "Value", "Share"]}
rows={data.map((d) => [d.label, formatValue(d.value), pct(d.value)])}
/>
</div>
);
}
"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));
}
API Reference
dataDonutSlice[]Parts of one whole. Keep it to five or fewer and fold the long tail into an "Other" slice; close values compare better as bars.
—variant?"donut" | "pie""donut" (default) leaves room for the center readout; "pie" is solid.
donutsize?numberDiameter in px. Default 220.
220thickness?numberRing thickness for the donut, in px. Default 32% of the radius.
—totalLabel?stringCenter caption under the total. Default "Total".
Totallegend?booleanShow the legend under the chart. Default true.
trueformatValue?((value: number) => string)(n: number) => FULL.format(n)titlestringNames the chart for assistive tech and the table view.
—className?string—Radial Chart
radial-chart.tsxConcentric progress rings with rounded caps that fill clockwise toward a target.
Install
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))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { useRef, useState } from "react";
import {
ChartLegend,
ChartTable,
formatFull,
seriesColor,
stepIndex,
} from "@/components/motion/chart/shared";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type RadialRing = {
label: string;
value: number;
/** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */
color?: string;
};
export interface RadialChartProps {
/** One ring per item, outermost first. A single item makes a progress ring. */
data: RadialRing[];
/** The value a full ring represents. Default 100. */
max?: number;
/** Diameter in px. Default 220. */
size?: number;
/** Ring thickness in px. Default 14. */
thickness?: number;
/** Center content when nothing is hovered. Default: first ring's share. */
centerValue?: string;
centerLabel?: string;
/** Show the legend. Default: when there are two or more rings. */
legend?: boolean;
formatValue?: (value: number) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const RING_GAP = 6;
/**
* Progress against a target as concentric rings that fill clockwise from
* 12 o'clock with rounded caps. Hovering or arrowing to a ring dims the
* others and swaps the center readout to that ring.
*/
export function RadialChart({
data,
max = 100,
size = 220,
thickness = 14,
centerValue,
centerLabel,
legend,
formatValue = formatFull,
title,
className,
}: RadialChartProps) {
const reduce = useReducedMotion();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const shown = reduce || inView;
const c = size / 2;
const share = (v: number) => Math.min(1, Math.max(0, v / max));
const pct = (v: number) => `${Math.round(share(v) * 100)}%`;
const shownIndex = active ?? 0;
const center =
active === null && centerValue !== undefined
? { key: "custom", value: centerValue, label: centerLabel ?? "" }
: {
key: `r${shownIndex}`,
value: pct(data[shownIndex]?.value ?? 0),
label:
active === null && centerLabel !== undefined
? centerLabel
: `${data[shownIndex]?.label ?? ""} · ${formatValue(data[shownIndex]?.value ?? 0)}`,
};
return (
<div ref={viewRef} className={cn("flex flex-col items-center", className)}>
{/* biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the rings */}
<div
role="group"
aria-label={`${title}. Use arrow keys to read each ring.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={(e) => {
const next = stepIndex(e.key, active, data.length, true);
if (next === undefined) return;
e.preventDefault();
setActive(next);
}}
onBlur={() => setActive(null)}
className="relative rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
aria-hidden
onPointerLeave={() => setActive(null)}
// Rings start at 12 o'clock and fill clockwise.
className="-rotate-90"
>
<title>{title}</title>
{data.map((ring, i) => {
const r = c - thickness / 2 - i * (thickness + RING_GAP);
if (r <= thickness) return null;
const color = seriesColor(ring, i);
const dim = active !== null && active !== i;
return (
<g key={ring.label} onPointerEnter={() => setActive(i)}>
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke="var(--border-strong)"
strokeWidth={thickness}
/>
<motion.circle
cx={c}
cy={c}
r={r}
fill="none"
stroke={color}
strokeWidth={thickness}
strokeLinecap="round"
initial={false}
animate={{
pathLength: shown ? share(ring.value) : 0,
opacity: dim ? 0.3 : 1,
}}
transition={{
pathLength: reduce
? { duration: 0 }
: { duration: 1, ease: EASE_OUT, delay: i * 0.12 },
opacity: { duration: 0.15 },
}}
/>
</g>
);
})}
</svg>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={center.key}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: { opacity: 0, y: -6, filter: "blur(4px)", transition: { duration: 0.12, ease: EASE_OUT } }
}
transition={{ duration: 0.2, ease: EASE_OUT }}
className="flex max-w-[60%] flex-col items-center text-center"
>
<span className="text-3xl font-bold tracking-tight text-foreground tabular-nums">
{center.value}
</span>
<span className="truncate text-xs text-muted-foreground">
{center.label}
</span>
</motion.div>
</AnimatePresence>
</div>
</div>
{(legend ?? data.length > 1) && (
<ChartLegend
items={data.map((ring, i) => ({ label: ring.label, color: seriesColor(ring, i) }))}
/>
)}
<ChartTable
caption={title}
head={["Ring", "Value", `Share of ${formatValue(max)}`]}
rows={data.map((ring) => [ring.label, formatValue(ring.value), pct(ring.value)])}
/>
</div>
);
}
"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));
}
API Reference
dataRadialRing[]One ring per item, outermost first. A single item makes a progress ring.
—max?numberThe value a full ring represents. Default 100.
100size?numberDiameter in px. Default 220.
220thickness?numberRing thickness in px. Default 14.
14centerValue?stringCenter content when nothing is hovered. Default: first ring's share.
—centerLabel?string—legend?booleanShow the legend. Default: when there are two or more rings.
—formatValue?((value: number) => string)(n: number) => FULL.format(n)titlestringNames the chart for assistive tech and the table view.
—className?string—Sparkline
sparkline.tsxWord-sized trend line for stat tiles that ends on the latest value, with a spoken summary.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
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 { 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));
}
API Reference
datanumber[]Values in time order.
—height?numberHeight in px; width fills the container. Default 40.
40color?stringAny CSS color. Default: the first `--chart-*` slot.
var(--chart-1, #2a78d6)area?booleanSoft fill under the line. Default true.
trueformatValue?((value: number) => string)(n: number) => FULL.format(n)labelstringWhat the values are, e.g. "Weekly revenue". Used in the spoken summary.
—className?string—Heatmap Chart
heatmap-chart.tsxGitHub-style contribution calendar; cells fade in week by week and arrow keys read each day.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
import { motion, useInView, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useEffect,
useRef,
useState,
} from "react";
import {
ChartTable,
ChartTooltip,
formatFull,
GROW,
seriesColor,
useChartWidth,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export type HeatmapDatum = {
/** Calendar day, `YYYY-MM-DD` (read as UTC). */
date: string;
value: number;
};
export interface HeatmapChartProps {
/** One entry per day; missing days count as zero, repeated days are summed. */
data: HeatmapDatum[];
/** Columns to show, ending on the latest date in `data`. Default 53 (a year). */
weeks?: number;
/** Intensity steps including the empty step. Default 5. */
levels?: number;
/** Any CSS color for the strongest step. Default `--chart-1`. */
color?: string;
/** Tooltip value text. Default "N contributions". */
formatValue?: (value: number) => string;
/** Tooltip date text from a `YYYY-MM-DD` string. Default "Oct 3". */
formatDate?: (date: string) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const DAY_MS = 86_400_000;
const LEFT = 30; // weekday labels
const TOP = 20; // month labels
// Cell pitch bounds: below MIN the grid scrolls; above MAX it stops growing.
const MIN_PITCH = 8;
const MAX_PITCH = 18;
const WEEKDAYS = [
[1, "Mon"],
[3, "Wed"],
[5, "Fri"],
] as const;
// Fixed locale + UTC keep server and client output identical for hydration.
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const SHORT = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
const LONG = new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
/** Days since the epoch, from `YYYY-MM-DD`, in UTC. */
function toDay(iso: string) {
const [y, m, d] = iso.split("-").map(Number);
return Math.floor(Date.UTC(y, (m || 1) - 1, d || 1) / DAY_MS);
}
const toIso = (day: number) => new Date(day * DAY_MS).toISOString().slice(0, 10);
/** 0 = Sunday. 1970-01-01 was a Thursday. */
const weekday = (day: number) => (((day + 4) % 7) + 7) % 7;
const defaultFormatValue = (v: number) =>
`${formatFull(v)} ${v === 1 ? "contribution" : "contributions"}`;
const defaultFormatDate = (iso: string) => SHORT.format(new Date(`${iso}T00:00:00Z`));
/**
* Contribution calendar: one rounded cell per day, a column per week, shaded
* in `levels` steps of one color. Cells fade and scale in column by column when
* scrolled into view; hovering or arrowing (up/down by day, left/right by
* week) reads a day. Sizes itself to the available width and scrolls
* horizontally, newest week in view, when it can't fit.
*/
export function HeatmapChart({
data,
weeks = 53,
levels = 5,
color = seriesColor({}, 0),
formatValue = defaultFormatValue,
formatDate = defaultFormatDate,
title,
className,
}: HeatmapChartProps) {
const reduce = useReducedMotion();
const [measureRef, width] = useChartWidth();
const viewRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0);
const shown = reduce || inView;
const steps = Math.max(2, Math.round(levels));
const cols = Math.max(1, Math.round(weeks));
const values = new Map<number, number>();
let end = Number.NEGATIVE_INFINITY;
for (const d of data) {
const day = toDay(d.date);
if (Number.isNaN(day)) continue;
values.set(day, (values.get(day) ?? 0) + (Number(d.value) || 0));
end = Math.max(end, day);
}
const hasData = Number.isFinite(end);
const first = hasData ? end - weekday(end) - (cols - 1) * 7 : 0;
const count = hasData ? end - first + 1 : 0;
const cells = Array.from({ length: count }, (_, i) => {
const day = first + i;
return { day, iso: toIso(day), value: values.get(day) ?? 0 };
});
const max = Math.max(0, ...cells.map((c) => c.value));
const level = (v: number) =>
v <= 0 || max <= 0
? 0
: Math.min(steps - 1, Math.max(1, Math.ceil((v / max) * (steps - 1))));
const fill = (l: number) => {
if (l === 0) return "var(--border-strong)";
const pct = steps === 2 ? 100 : Math.round(30 + (70 * (l - 1)) / (steps - 2));
return `color-mix(in oklab, ${color} ${pct}%, transparent)`;
};
const pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, (width - LEFT) / cols));
const gap = Math.max(2, pitch * 0.18);
const cell = pitch - gap;
const svgW = LEFT + cols * pitch - gap;
const svgH = TOP + 7 * pitch - gap;
const cellX = (i: number) => LEFT + Math.floor(i / 7) * pitch;
const cellY = (i: number) => TOP + (i % 7) * pitch;
// Month label over the first column of each month; a first label crowded by
// the next one is dropped.
const months: { col: number; text: string }[] = [];
for (let c = 0; c < cols; c++) {
const m = new Date((first + c * 7) * DAY_MS).getUTCMonth();
const prev = c === 0 ? -1 : new Date((first + (c - 1) * 7) * DAY_MS).getUTCMonth();
if (m !== prev) months.push({ col: c, text: MONTH.format(new Date((first + c * 7) * DAY_MS)) });
}
if (months.length > 1 && months[1].col - months[0].col < 3) months.shift();
// Narrow screens: start on the newest weeks, the end people read first.
const overflows = svgW > width;
useEffect(() => {
const el = scrollRef.current;
if (!el || !overflows) return;
el.scrollLeft = el.scrollWidth;
setScrollLeft(el.scrollLeft);
}, [overflows]);
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const col = Math.floor((e.clientX - box.left - LEFT) / pitch);
const row = Math.floor((e.clientY - box.top - TOP) / pitch);
const i = col * 7 + row;
if (col < 0 || row < 0 || row > 6 || i >= count) setActive(null);
else setActive(i);
};
const onKeyDown = (e: KeyboardEvent<SVGSVGElement>) => {
const deltas: Record<string, number> = {
ArrowUp: -1,
ArrowDown: 1,
ArrowLeft: -7,
ArrowRight: 7,
};
let next: number;
if (e.key === "Home") next = 0;
else if (e.key === "End") next = count - 1;
else if (e.key in deltas) {
if (active === null) next = count - 1;
else {
const moved = active + deltas[e.key];
next = moved < 0 || moved >= count ? active : moved;
}
} else return;
e.preventDefault();
setActive(next);
// Keep the read cell inside the scroll window.
const el = scrollRef.current;
if (el && overflows) {
const x = cellX(next);
if (x < el.scrollLeft + LEFT) el.scrollLeft = x - LEFT;
else if (x + pitch > el.scrollLeft + el.clientWidth) {
el.scrollLeft = x + pitch - el.clientWidth;
}
}
};
const activeCell = active === null ? null : cells[active];
return (
<div ref={viewRef} className={cn("w-full", className)}>
<div ref={measureRef} className="relative w-full">
<div
ref={scrollRef}
onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
className={cn("w-full", overflows && "overflow-x-auto pb-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]")}
>
{width > 0 && count > 0 && (
// biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot
<svg
width={svgW}
height={svgH}
role="group"
aria-label={`${title}. Use arrow keys to read each day: up and down by day, left and right by week.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={onKeyDown}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
onBlur={() => setActive(null)}
className="block touch-pan-x touch-pan-y rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{months.map((m) => (
<text
key={m.col}
x={LEFT + m.col * pitch}
y={TOP - 7}
className="fill-muted-foreground text-xs"
>
{m.text}
</text>
))}
{WEEKDAYS.map(([row, text]) => (
<text
key={text}
x={0}
y={TOP + row * pitch + cell / 2}
dy="0.32em"
className="fill-muted-foreground text-[10px]"
>
{text}
</text>
))}
<motion.g
initial={reduce ? false : "hidden"}
animate={shown ? "shown" : "hidden"}
>
{cells.map((c, i) => (
<motion.rect
key={c.day}
x={cellX(i)}
y={cellY(i)}
width={cell}
height={cell}
rx={Math.min(3, cell * 0.22)}
fill={fill(level(c.value))}
custom={Math.floor(i / 7)}
variants={{
hidden: { opacity: 0, scale: reduce ? 1 : 0.4 },
shown: (col: number) => ({
opacity: 1,
scale: 1,
transition: reduce
? { duration: 0 }
: { ...GROW, duration: 0.45, delay: col * 0.012 },
}),
}}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
/>
))}
</motion.g>
{active !== null && (
<motion.rect
initial={false}
animate={{ x: cellX(active) - 1, y: cellY(active) - 1 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40 }
}
width={cell + 2}
height={cell + 2}
rx={Math.min(4, cell * 0.25)}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.5}
pointerEvents="none"
/>
)}
</svg>
)}
</div>
<ChartTooltip
open={activeCell !== null}
x={active === null ? 0 : cellX(active) + cell / 2 - scrollLeft}
y={active === null ? 0 : cellY(active) + cell / 2}
width={width}
label={
activeCell
? `${formatValue(activeCell.value)} · ${formatDate(activeCell.iso)}`
: ""
}
rows={[]}
/>
</div>
<div className="flex items-center justify-end gap-1 pt-3 text-xs text-muted-foreground">
<span className="mr-1">Less</span>
{Array.from({ length: steps }, (_, l) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: steps are a fixed ordered scale.
key={l}
aria-hidden
className="size-2.5 shrink-0 rounded-[2px]"
style={{ background: fill(l) }}
/>
))}
<span className="ml-1">More</span>
</div>
<ChartTable
caption={title}
head={["Date", "Value"]}
rows={cells.map((c) => [
LONG.format(new Date(c.day * DAY_MS)),
formatValue(c.value),
])}
/>
</div>
);
}
"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));
}
API Reference
dataHeatmapDatum[]One entry per day; missing days count as zero, repeated days are summed.
—weeks?numberColumns to show, ending on the latest date in `data`. Default 53 (a year).
53levels?numberIntensity steps including the empty step. Default 5.
5color?stringAny CSS color for the strongest step. Default `--chart-1`.
seriesColor({}, 0)formatValue?((value: number) => string)Tooltip value text. Default "N contributions".
(v: number) =>
`${formatFull(v)} ${v === 1 ? "contribution" : "contributions"}`formatDate?((date: string) => string)Tooltip date text from a `YYYY-MM-DD` string. Default "Oct 3".
(iso: string) => SHORT.format(new Date(`${iso}T00:00:00Z`))titlestringNames the chart for assistive tech and the table view.
—className?string—Gauge Chart
gauge-chart.tsxArc meter with threshold bands; the fill and a springy needle swing to the value together.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
// 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/motion/chart
import {
motion,
useInView,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef } from "react";
import {
ChartLegend,
formatFull,
seriesColor,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export type GaugeSegment = {
/** Upper bound of this band; it starts where the previous one ends (or at `min`). */
to: number;
label: string;
/** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */
color?: string;
};
export interface GaugeChartProps {
value: number;
/** Default 0. */
min?: number;
/** Default 100. */
max?: number;
/** Threshold bands, ascending. The fill takes the color of the band the value sits in. */
segments?: GaugeSegment[];
/** Caption under the value. Default: the active segment's label. */
label?: string;
/** Fill color when there are no segments. Default `--chart-1`. */
color?: string;
/** Arc length in degrees, 180 (semicircle) to 300. Default 240. */
sweep?: number;
/** Width in px. Default 260. */
size?: number;
/** Arc thickness in px. Default 16. */
thickness?: number;
/** Show the segment legend. Default: when there are segments. */
legend?: boolean;
formatValue?: (value: number) => string;
/** Names the meter for assistive tech. */
title: string;
className?: string;
}
// A needle wants a little overshoot and settle, unlike UI panels: softer and
// less damped than SPRING_LAYOUT so the swing reads as a physical pointer.
const SPRING_NEEDLE = { stiffness: 140, damping: 16, mass: 0.9 } as const;
const BAND_GAP = 5; // between the threshold band and the arc
const BAND_W = 4;
/** Point on a circle; angle in degrees, 0 = 12 o'clock, clockwise. */
function polar(cx: number, cy: number, r: number, deg: number) {
const a = (deg * Math.PI) / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)] as const;
}
function arc(cx: number, cy: number, r: number, from: number, to: number) {
const [x0, y0] = polar(cx, cy, r, from);
const [x1, y1] = polar(cx, cy, r, to);
const large = to - from > 180 ? 1 : 0;
return `M${x0},${y0}A${r},${r} 0 ${large} 1 ${x1},${y1}`;
}
/**
* A single value on a scale: an arc that fills with `pathLength` and a needle
* that springs to the value, both driven by one spring so they never drift
* apart. Optional threshold segments ring the arc and color the fill by the
* band the value falls in. Exposed to assistive tech as a meter.
*/
export function GaugeChart({
value,
min = 0,
max = 100,
segments,
label,
color,
sweep = 240,
size = 260,
thickness = 16,
legend,
formatValue = formatFull,
title,
className,
}: GaugeChartProps) {
const reduce = useReducedMotion();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.4 });
const shown = reduce || inView;
const span = max - min || 1;
const clamped = Math.min(max, Math.max(min, value));
const frac = (clamped - min) / span;
const deg = Math.min(300, Math.max(180, sweep));
const start = -deg / 2;
const bands = (segments ?? []).map((s, i, all) => ({
...s,
from: i === 0 ? min : all[i - 1].to,
color: seriesColor(s, i),
}));
const current =
bands.find((b) => clamped <= b.to) ?? bands[bands.length - 1] ?? null;
const fillColor = current?.color ?? color ?? seriesColor({}, 0);
const caption = label ?? current?.label ?? "";
// Geometry: the band rings the arc on the outside; leave room for it.
const outer = bands.length ? BAND_GAP + BAND_W : 0;
const r = size / 2 - thickness / 2 - outer - 2;
const cx = size / 2;
const cy = size / 2;
// Lowest point of the arc ends (below the pivot once the sweep passes 180°).
const endDrop = Math.max(0, -Math.cos((deg / 2) * (Math.PI / 180))) * (r + outer);
const readoutTop = cy + 14;
const height = Math.max(cy + endDrop + thickness / 2 + 22, readoutTop + 58);
// One spring drives both the fill and the needle.
const progress = useSpring(0, SPRING_NEEDLE);
useEffect(() => {
const target = shown ? frac : 0;
if (reduce) progress.jump(target);
else progress.set(target);
}, [frac, shown, reduce, progress]);
const pathLength = useTransform(progress, (p) => Math.max(0, p));
const needleLen = r - thickness / 2 - 8;
// Needle endpoints computed from the angle: motion rewrites
// transform-origin on SVG children, so rotating a group drifts off the hub.
const angle = useTransform(progress, (p) => ((start + p * deg) * Math.PI) / 180);
const tipX = useTransform(angle, (a) => cx + needleLen * Math.sin(a));
const tipY = useTransform(angle, (a) => cy - needleLen * Math.cos(a));
const tailX = useTransform(angle, (a) => cx - 6 * Math.sin(a));
const tailY = useTransform(angle, (a) => cy + 6 * Math.cos(a));
const [minX, minY] = polar(cx, cy, r, start);
const [maxX, maxY] = polar(cx, cy, r, -start);
return (
<div ref={viewRef} className={cn("flex flex-col items-center", className)}>
{/* biome-ignore lint/a11y/useSemanticElements: custom-drawn meter; <meter> can't contain the SVG */}
<div
role="meter"
aria-label={title}
aria-valuenow={clamped}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={caption ? `${formatValue(clamped)}, ${caption}` : formatValue(clamped)}
className="relative"
style={{ width: size, height }}
>
<svg width={size} height={height} aria-hidden className="overflow-visible">
<title>{title}</title>
{bands.map((b) => {
const a0 = start + ((b.from - min) / span) * deg;
const a1 = start + ((Math.min(max, b.to) - min) / span) * deg;
if (a1 <= a0) return null;
return (
<path
key={b.label}
d={arc(cx, cy, r + thickness / 2 + BAND_GAP + BAND_W / 2, a0 + 0.6, a1 - 0.6)}
fill="none"
stroke={b.color}
strokeWidth={BAND_W}
strokeOpacity={b === current ? 1 : 0.45}
/>
);
})}
<path
d={arc(cx, cy, r, start, -start)}
fill="none"
stroke="var(--border-strong)"
strokeWidth={thickness}
strokeLinecap="round"
/>
<motion.path
d={arc(cx, cy, r, start, -start)}
fill="none"
stroke={fillColor}
strokeWidth={thickness}
strokeLinecap="round"
initial={false}
animate={{ opacity: shown && frac > 0 ? 1 : 0 }}
transition={{ opacity: { duration: 0.2 } }}
style={{ pathLength, transition: "stroke 200ms" }}
/>
<text
x={minX}
y={minY + thickness / 2 + 14}
textAnchor="middle"
className="fill-muted-foreground text-xs tabular-nums"
>
{formatValue(min)}
</text>
<text
x={maxX}
y={maxY + thickness / 2 + 14}
textAnchor="middle"
className="fill-muted-foreground text-xs tabular-nums"
>
{formatValue(max)}
</text>
<motion.line
x1={tailX}
y1={tailY}
x2={tipX}
y2={tipY}
stroke="var(--foreground)"
strokeWidth={3}
strokeLinecap="round"
/>
<circle cx={cx} cy={cy} r={7} fill="var(--foreground)" stroke="var(--card)" strokeWidth={3} />
</svg>
<div
className="pointer-events-none absolute inset-x-0 flex flex-col items-center text-center"
style={{ top: readoutTop }}
>
<span className="text-3xl font-bold tracking-tight text-foreground tabular-nums">
{formatValue(clamped)}
</span>
{caption ? (
<span className="max-w-[70%] truncate text-xs text-muted-foreground">
{caption}
</span>
) : null}
</div>
</div>
{(legend ?? bands.length > 0) && bands.length > 0 && (
<ChartLegend items={bands.map((b) => ({ label: b.label, color: b.color }))} />
)}
</div>
);
}
"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));
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
// The ripple layer clips itself, so on a touch screen the button can
// stop clipping and let its enlarged hit area overhang.
ripple && "overflow-hidden pointer-coarse:overflow-visible",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
valuenumber—min?numberDefault 0.
0max?numberDefault 100.
100segments?GaugeSegment[]Threshold bands, ascending. The fill takes the color of the band the value sits in.
—label?stringCaption under the value. Default: the active segment's label.
—color?stringFill color when there are no segments. Default `--chart-1`.
—sweep?numberArc length in degrees, 180 (semicircle) to 300. Default 240.
240size?numberWidth in px. Default 260.
260thickness?numberArc thickness in px. Default 16.
16legend?booleanShow the segment legend. Default: when there are segments.
—formatValue?((value: number) => string)(n: number) => FULL.format(n)titlestringNames the meter for assistive tech.
—className?string—Funnel Chart
funnel-chart.tsxHorizontal conversion funnel whose stages grow in one by one; hovering a stage reads its step conversion.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/chart
import { motion, useInView, useReducedMotion } from "motion/react";
import { useRef, useState } from "react";
import {
ChartTable,
ChartTooltip,
formatFull,
GROW,
seriesColor,
stepIndex,
useChartWidth,
} from "@/components/motion/chart/shared";
import { cn } from "@/lib/utils";
export type FunnelStage = {
label: string;
value: number;
};
export interface FunnelChartProps {
/** Ordered steps, widest first (visits → signups → purchases…). */
data: FunnelStage[];
/** Plot height in px. Default 180. */
height?: number;
/** Any CSS color; later stages fade toward the surface. Default `--chart-1`. */
color?: string;
/** Print each step's conversion from the previous one. Default true. */
showConversion?: boolean;
formatValue?: (value: number) => string;
/** Names the chart for assistive tech and the table view. */
title: string;
className?: string;
}
const GAP = 2; // hairline between stages
const MIN_BAND = 0.04; // a near-empty stage still shows a sliver
const PCT = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
});
/**
* Conversion through ordered steps as one horizontal funnel: each stage is a
* band whose height follows its value and eases into the next stage's height.
* Bands grow out of the center line one after another when scrolled into
* view; hovering or arrowing to a stage dims the rest and reads its value and
* conversion from the previous step and from the top of the funnel.
*/
export function FunnelChart({
data,
height = 180,
color = seriesColor({}, 0),
showConversion = true,
formatValue = formatFull,
title,
className,
}: FunnelChartProps) {
const reduce = useReducedMotion();
const [measureRef, width] = useChartWidth();
const viewRef = useRef<HTMLDivElement>(null);
const inView = useInView(viewRef, { once: true, amount: 0.3 });
const [active, setActive] = useState<number | null>(null);
const shown = reduce || inView;
const n = data.length;
const top = Math.max(0, ...data.map((d) => d.value));
const share = (v: number) =>
top > 0 ? Math.max(MIN_BAND, Math.max(0, v) / top) : MIN_BAND;
const ofPrev = (i: number) =>
i === 0 || data[i - 1].value <= 0 ? 1 : data[i].value / data[i - 1].value;
const ofTop = (i: number) =>
data[0].value > 0 ? data[i].value / data[0].value : 0;
const colW = n ? width / n : 0;
const cy = height / 2;
const half = (i: number) => (share(data[Math.min(i, n - 1)].value) * height) / 2;
// Later stages fade toward the surface but stay clearly colored.
const fillFor = (i: number) =>
n <= 1
? color
: `color-mix(in oklab, ${color} ${Math.round(100 - (45 * i) / (n - 1))}%, transparent)`;
// Each band holds its height for the first part of the column, then eases
// (S-curve) into the next stage's height.
const bandPath = (i: number) => {
const x0 = i * colW + (i === 0 ? 0 : GAP / 2);
const x1 = (i + 1) * colW - (i === n - 1 ? 0 : GAP / 2);
const h0 = half(i);
const h1 = half(i + 1);
const flat = x0 + (x1 - x0) * 0.35;
const mid = (flat + x1) / 2;
return [
`M${x0},${cy - h0}`,
`L${flat},${cy - h0}`,
`C${mid},${cy - h0} ${mid},${cy - h1} ${x1},${cy - h1}`,
`L${x1},${cy + h1}`,
`C${mid},${cy + h1} ${mid},${cy + h0} ${flat},${cy + h0}`,
`L${x0},${cy + h0}Z`,
].join("");
};
return (
<div ref={viewRef} className={cn("w-full", className)}>
<div
className="grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, n)}, minmax(0, 1fr))` }}
>
{data.map((d, i) => (
<motion.div
key={d.label}
initial={false}
animate={{ opacity: active !== null && active !== i ? 0.45 : 1 }}
transition={{ duration: 0.15 }}
className={cn("min-w-0 pb-3", i > 0 && "border-l border-border pl-3")}
>
<p className="truncate text-xs text-muted-foreground">{d.label}</p>
<p className="truncate text-lg font-semibold tracking-tight text-foreground tabular-nums">
{formatValue(d.value)}
</p>
{showConversion && (
<p className="truncate text-xs text-muted-foreground tabular-nums">
{i === 0 ? " " : `${PCT.format(ofPrev(i))} of previous`}
</p>
)}
</motion.div>
))}
</div>
<div ref={measureRef} className="relative w-full">
{width > 0 && n > 0 && (
// biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot
<svg
width={width}
height={height}
role="group"
aria-label={`${title}. Use arrow keys to read each stage.`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values
tabIndex={0}
onKeyDown={(e) => {
const next = stepIndex(e.key, active, n);
if (next === undefined) return;
e.preventDefault();
setActive(next);
}}
onPointerMove={(e) => {
const box = e.currentTarget.getBoundingClientRect();
const i = Math.floor((e.clientX - box.left) / (colW || 1));
setActive(Math.min(n - 1, Math.max(0, i)));
}}
onPointerLeave={() => setActive(null)}
onBlur={() => setActive(null)}
className="block touch-pan-y rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{data.slice(1).map((d, i) => (
<line
key={d.label}
x1={(i + 1) * colW}
x2={(i + 1) * colW}
y1={0}
y2={height}
stroke="var(--border-strong)"
strokeDasharray="3 3"
shapeRendering="crispEdges"
/>
))}
{data.map((d, i) => (
<motion.path
key={d.label}
d={bandPath(i)}
fill={fillFor(i)}
initial={reduce ? false : { scaleY: 0 }}
animate={{
opacity: active !== null && active !== i ? 0.35 : 1,
scaleY: shown ? 1 : 0,
}}
transition={{
scaleY: reduce
? { duration: 0 }
: { ...GROW, delay: 0.1 + i * 0.12 },
opacity: { duration: 0.15 },
}}
style={{ transformBox: "view-box", transformOrigin: `0px ${cy}px` }}
/>
))}
</svg>
)}
<ChartTooltip
open={active !== null}
x={active === null ? 0 : (active + 0.5) * colW}
y={cy}
width={width}
label={active === null ? "" : data[active].label}
rows={
active === null
? []
: [
{ label: "Value", value: formatValue(data[active].value), color: fillFor(active) },
...(active > 0
? [
{ label: "Of previous", value: PCT.format(ofPrev(active)), color: fillFor(active) },
{ label: "Of first", value: PCT.format(ofTop(active)), color: fillFor(active) },
]
: []),
]
}
/>
</div>
<ChartTable
caption={title}
head={["Stage", "Value", "Of previous", "Of first"]}
rows={data.map((d, i) => [
d.label,
formatValue(d.value),
i === 0 ? "—" : PCT.format(ofPrev(i)),
PCT.format(ofTop(i)),
])}
/>
</div>
);
}
"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));
}
API Reference
dataFunnelStage[]Ordered steps, widest first (visits → signups → purchases…).
—height?numberPlot height in px. Default 180.
180color?stringAny CSS color; later stages fade toward the surface. Default `--chart-1`.
seriesColor({}, 0)showConversion?booleanPrint each step's conversion from the previous one. Default true.
trueformatValue?((value: number) => string)(n: number) => FULL.format(n)titlestringNames the chart for assistive tech and the table view.
—className?string—Updated