Line Chart
Line and area charts that morph with your data, with a snapping crosshair and value tooltips.
Preview
Monthly revenue
Demo data, in thousands of dollars
| Category | This year | Last year | Target |
|---|---|---|---|
| Jan | $36.4K | $36.5K | $36.0K |
| Feb | $43.0K | $35.8K | $38.5K |
| Mar | $43.9K | $36.3K | $41.0K |
| Apr | $44.2K | $39.9K | $43.5K |
| May | $49.1K | $43.3K | $46.0K |
| Jun | $52.2K | $40.9K | $48.5K |
| Jul | $53.6K | $44.2K | $51.0K |
| Aug | $58.7K | $45.6K | $53.5K |
| Sep | $63.8K | $51.1K | $56.0K |
| Oct | $67.1K | $55.5K | $58.5K |
| Nov | $71.6K | $53.7K | $61.0K |
| Dec | $75.6K | $55.5K | $63.5K |
"use client";
import {
LineChart,
LineChartLegend,
LineChartPlot,
type LineChartSeries,
LineChartTooltip,
} from "@/components/charts/line-chart";
/** Pass one value per category for each series; null leaves a gap. Keep series
* ids stable and new values morph the lines instead of redrawing them. */
export function LineChartExample({
series,
categories,
}: {
series: readonly LineChartSeries[];
categories: readonly string[];
}) {
return (
<LineChart
series={series}
categories={categories}
variant="area"
label="Monthly revenue"
formatValue={(value) => `$${value.toLocaleString("en-US")}`}
>
<LineChartPlot height={280} />
<LineChartTooltip />
<LineChartLegend />
</LineChart>
);
}
"use client";
// beui.dev/charts/line-chart
import { cn } from "@/lib/utils";
import { LineChartContext, type LineChartProps, useLineChartModel } from "./line-chart/context";
import { LineChartLegend } from "./line-chart/legend";
import { LineChartPlot } from "./line-chart/plot";
import { LineChartTooltip } from "./line-chart/tooltip";
import { XYDataTable } from "./xy/data-table";
/** Compose Plot, Tooltip and Legend, or omit children for the complete chart. */
export function LineChart({
series,
categories,
variant,
curve,
domain,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
status,
loadingLabel,
children,
className,
...props
}: LineChartProps) {
const model = useLineChartModel({
series,
categories,
variant,
curve,
domain,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
status,
loadingLabel,
});
return (
<LineChartContext.Provider value={model}>
<section
aria-label={model.label}
aria-busy={model.loading || undefined}
{...props}
data-slot="line-chart"
className={cn("w-full min-w-0 space-y-3", className)}
>
{children === undefined ? (
<>
<LineChartPlot />
<LineChartTooltip />
{model.rows.length > 1 ? <LineChartLegend /> : null}
</>
) : (
children
)}
{/* Placeholder or stale values are not data yet. */}
{model.loading ? null : (
<XYDataTable
label={model.label}
rows={model.rows}
categories={model.categories}
formatValue={model.formatValue}
/>
)}
</section>
</LineChartContext.Provider>
);
}
export type { LineChartProps } from "./line-chart/context";
export { useLineChart } from "./line-chart/context";
export type { LineChartLegendProps } from "./line-chart/legend";
export { LineChartLegend } from "./line-chart/legend";
export type { LineChartPlotProps } from "./line-chart/plot";
export { LineChartPlot } from "./line-chart/plot";
export type { LineChartTooltipData, LineChartTooltipProps } from "./line-chart/tooltip";
export { LineChartTooltip } from "./line-chart/tooltip";
export type { XYSeries as LineChartSeries } from "./xy/model";
export type { XYCurve as LineChartCurve } from "./xy/path";
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"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 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 type { CSSProperties } from "react";
// The reduced-motion rule travels with the component. app/globals.css calms CSS
// animation globally, but the registry bundles imports only, so an installed
// copy never receives that reset.
//
// `!important` because the sweep is an inline style, which outranks a plain rule
// in a media query. It selects a marker class carried by TEXT_SHIMMER_CLASS_NAME
// so it also reaches consumers that build their own span out of these exports.
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}" +
"@media (prefers-reduced-motion: reduce){.beui-text-shimmer{animation:none !important}}";
export const TEXT_SHIMMER_CLASS_NAME =
"beui-text-shimmer bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]";
export function textShimmerStyle(duration: number): CSSProperties {
return {
animation: `beui-text-shimmer ${duration}s linear infinite`,
};
}
Copy the source code
"use client";
// beui.dev/charts/line-chart
import { cn } from "@/lib/utils";
import { LineChartContext, type LineChartProps, useLineChartModel } from "./line-chart/context";
import { LineChartLegend } from "./line-chart/legend";
import { LineChartPlot } from "./line-chart/plot";
import { LineChartTooltip } from "./line-chart/tooltip";
import { XYDataTable } from "./xy/data-table";
/** Compose Plot, Tooltip and Legend, or omit children for the complete chart. */
export function LineChart({
series,
categories,
variant,
curve,
domain,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
status,
loadingLabel,
children,
className,
...props
}: LineChartProps) {
const model = useLineChartModel({
series,
categories,
variant,
curve,
domain,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
status,
loadingLabel,
});
return (
<LineChartContext.Provider value={model}>
<section
aria-label={model.label}
aria-busy={model.loading || undefined}
{...props}
data-slot="line-chart"
className={cn("w-full min-w-0 space-y-3", className)}
>
{children === undefined ? (
<>
<LineChartPlot />
<LineChartTooltip />
{model.rows.length > 1 ? <LineChartLegend /> : null}
</>
) : (
children
)}
{/* Placeholder or stale values are not data yet. */}
{model.loading ? null : (
<XYDataTable
label={model.label}
rows={model.rows}
categories={model.categories}
formatValue={model.formatValue}
/>
)}
</section>
</LineChartContext.Provider>
);
}
export type { LineChartProps } from "./line-chart/context";
export { useLineChart } from "./line-chart/context";
export type { LineChartLegendProps } from "./line-chart/legend";
export { LineChartLegend } from "./line-chart/legend";
export type { LineChartPlotProps } from "./line-chart/plot";
export { LineChartPlot } from "./line-chart/plot";
export type { LineChartTooltipData, LineChartTooltipProps } from "./line-chart/tooltip";
export { LineChartTooltip } from "./line-chart/tooltip";
export type { XYSeries as LineChartSeries } from "./xy/model";
export type { XYCurve as LineChartCurve } from "./xy/path";
"use client";
import { useReducedMotion } from "motion/react";
import { type ComponentProps, createContext, type ReactNode, useContext, useMemo, useState } from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { normalizeXY, type XYSeries } from "../xy/model";
import type { XYCurve } from "../xy/path";
import { niceDomain } from "../xy/scale";
import { useControllable, useInspection } from "../xy/use-inspection";
export interface LineChartProps extends Omit<ComponentProps<"section">, "children"> {
series: readonly XYSeries[];
/** Unique x-axis labels in display order. Values align with them by index. */
categories: readonly string[];
/** `area` fills each line down to zero with a fading gradient. */
variant?: "line" | "area";
curve?: XYCurve;
/** Fixed value-axis bounds. Defaults to rounded bounds around the visible series. */
domain?: readonly [number, number];
/** Inspected category, by label. */
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string | null) => void;
/** Series ids hidden through the legend. */
hiddenSeries?: readonly string[];
defaultHiddenSeries?: readonly string[];
onHiddenSeriesChange?: (ids: readonly string[]) => void;
formatValue?: (value: number) => string;
/** Axis tick labels. Defaults to compact notation (1.2K). */
formatTick?: (value: number) => string;
label?: string;
/**
* `loading` hides the lines behind a pulsing placeholder until your data
* arrives; switching to `ready` hands off to the draw-in. Keep one chart
* mounted across both states.
*/
status?: "ready" | "loading";
/** Shown over the placeholder while loading. An empty string hides it. */
loadingLabel?: string;
children?: ReactNode;
}
const number = new Intl.NumberFormat("en", { maximumFractionDigits: 2 });
const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
const NONE: readonly string[] = [];
export function useLineChartModel({
series,
categories,
variant = "line",
curve = "monotone",
domain: fixedDomain,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries = NONE,
onHiddenSeriesChange,
formatValue = (value) => number.format(value),
formatTick = (value) => compact.format(value),
label = "Line chart",
status = "ready",
loadingLabel = "Loading data",
}: Omit<LineChartProps, "children">) {
// The plot area hosts the tooltip panel, and its measured size places it.
const [overlay, setOverlay] = useState<HTMLDivElement | null>(null);
const [geometry, setGeometry] = useState({ width: 0, height: 0 });
const data = useMemo(() => normalizeXY(series, categories), [series, categories]);
const [hidden, setHidden] = useControllable(hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange);
const visibleRows = data.rows.filter((row) => !hidden.includes(row.id));
const [hovered, setHovered] = useState<string | null>(null);
const highlight = visibleRows.some((row) => row.id === hovered) ? hovered : null;
// Hiding a series rescales the axis to what is left, and every mark springs there.
const domain = niceDomain(
visibleRows.flatMap((row) => row.values.filter((value): value is number => value !== null)),
{ includeZero: variant === "area", fixed: fixedDomain },
);
const inspection = useInspection({
ids: data.categories,
activeId,
defaultActiveId,
onActiveChange,
});
return {
...data,
...inspection,
visibleRows,
hidden,
toggle: (id: string) =>
setHidden(hidden.includes(id) ? hidden.filter((h) => h !== id) : [...hidden, id]),
highlight,
setHovered,
domain,
variant,
curve,
formatValue,
formatTick,
label,
status,
loading: status === "loading",
loadingLabel,
overlay,
setOverlay,
geometry,
setGeometry,
reduce: useReducedMotion() ?? false,
canHover: useHoverCapable(),
};
}
export type LineChartModel = ReturnType<typeof useLineChartModel>;
export const LineChartContext = createContext<LineChartModel | null>(null);
export function useLineChart() {
const context = useContext(LineChartContext);
if (!context) throw new Error("Line chart parts must be rendered inside LineChart.");
return context;
}
"use client";
import { XYLegend } from "../xy/legend";
import { useLineChart } from "./context";
export interface LineChartLegendProps {
className?: string;
}
/** Toggle a series to hide it; the axis rescales to what is left. */
export function LineChartLegend({ className }: LineChartLegendProps) {
const { rows, hidden, toggle, setHovered, canHover } = useLineChart();
return (
<XYLegend
rows={rows}
hidden={hidden}
onToggle={toggle}
onHover={setHovered}
canHover={canHover}
label="Series"
className={className}
/>
);
}
"use client";
/* Lines draw in left to right through a clip the first time the chart scrolls
* into view, and fade out at both ends of the plot. Every sample is a pair of
* springs keyed by series and category, so new data, a hidden series or a
* rescaled axis morph the lines, fills and dots together; a resize snaps.
*
* Inspecting glides a crosshair that fades toward its ends, haloed dots, and a
* pill on the category axis that rolls to the new label while the labels it
* would cover step aside. Everything right of the crosshair recedes, so the
* line reads "up to here".
*
* While loading, a placeholder line breathes under a travelling highlight.
* When data arrives it fades away and the real lines draw in.
*
* Reduced motion: no draw-in, springs, roll or travelling highlight; values
* update in place and the placeholder only pulses its opacity. */
import {
AnimatePresence,
type MotionValue,
motion,
type Transition,
useInView,
useTransform,
} from "motion/react";
import { useId, useLayoutEffect, useState } from "react";
import { TextShimmer } from "@/components/motion/text-shimmer";
import { EASE_IN_OUT, EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { XYGridLines, XYTickLabels } from "../xy/axis";
import { XYInspector } from "../xy/inspector";
import type { XYRow } from "../xy/model";
import { areaPath, linePath, type XYCurve } from "../xy/path";
import { labelStride } from "../xy/scale";
import { useAnimatedPoints, type XYAnimatedPoint, type XYPointTarget } from "../xy/use-points";
import { useElementWidth } from "../xy/use-width";
import { useLineChart } from "./context";
/** Left gutter for value labels, in px. */
const AXIS = 44;
const PAD = { top: 12, right: 16, bottom: 8 } as const;
const BASELINE = "baseline";
/** Category labels closer than this to the inspected one hide behind its pill, in px. */
const PILL_CLEAR = 40;
/** How much the line right of the crosshair recedes (mask darkness, 0 to 1). */
const RECEDE = 0.6;
const PLACEHOLDER_SAMPLES = 16;
const pointKey = (series: string, category: string) => JSON.stringify([series, category]);
export interface LineChartPlotProps {
/** Plot height in px, excluding the category labels. */
height?: number;
/** Stroke width of each line, in px. */
strokeWidth?: number;
showGrid?: boolean;
/** Fade lines and fills out toward the left and right edges of the plot. */
fadeEdges?: boolean;
className?: string;
}
export function LineChartPlot({
height = 288,
strokeWidth = 2,
showGrid = true,
fadeEdges = true,
className,
}: LineChartPlotProps) {
const {
rows,
categories,
visibleRows,
hidden,
highlight,
domain,
variant,
curve,
formatValue,
formatTick,
label,
loading,
loadingLabel,
activeIndex,
setActive,
open,
setOpen,
touching,
setTouching,
setOverlay,
setGeometry,
tooltipId,
reduce,
canHover,
} = useLineChart();
const { ref, width } = useElementWidth<HTMLDivElement>();
const inView = useInView(ref, { once: true, amount: 0.3 });
const id = useId();
const ids = {
clip: `${id}-clip`,
mask: `${id}-mask`,
edges: `${id}-edges`,
crosshair: `${id}-crosshair`,
pulse: `${id}-pulse`,
band: `${id}-band`,
};
const n = categories.length;
const plotWidth = Math.max(0, width - AXIS - PAD.right);
const innerHeight = height - PAD.top - PAD.bottom;
const x = (i: number) => AXIS + (n === 1 ? plotWidth / 2 : (i / (n - 1)) * plotWidth);
const y = (value: number) =>
PAD.top + ((domain.max - value) / (domain.max - domain.min || 1)) * innerHeight;
const baseline = y(Math.min(domain.max, Math.max(domain.min, 0)));
useLayoutEffect(() => {
setGeometry((previous) =>
previous.width === plotWidth && previous.height === innerHeight
? previous
: { width: plotWidth, height: innerHeight },
);
}, [plotWidth, innerHeight, setGeometry]);
const targets: XYPointTarget[] = [];
if (width > 0) {
targets.push({ key: BASELINE, x: 0, y: baseline });
for (const row of rows)
row.values.forEach((value, i) => {
if (value !== null)
targets.push({ key: pointKey(row.id, categories[i]), x: x(i), y: y(value) });
});
}
const points = useAnimatedPoints(targets, reduce, width);
const active = activeIndex >= 0 && !loading ? activeIndex : null;
const inspecting = open && active !== null;
// Roll the pill's label up when moving right and down when moving left.
const [lastActive, setLastActive] = useState(active);
const [direction, setDirection] = useState(1);
if (active !== lastActive) {
if (active !== null && lastActive !== null) setDirection(active < lastActive ? -1 : 1);
setLastActive(active);
}
if ((!n || !rows.length) && !loading)
return (
<p
className={cn("flex items-center justify-center text-sm text-muted-foreground", className)}
style={{ height }}
>
No data
</p>
);
const activeValues =
active === null
? []
: visibleRows.flatMap((row) => {
const value = row.values[active];
return value === null ? [] : [{ row, value }];
});
const stride = labelStride(n, plotWidth, 64);
const baselinePoint = points.get(BASELINE);
// Inspection marks glide between samples for a mouse or keys, but under a
// finger any glide reads as lag, so they track the touch directly.
const spring: Transition = reduce || touching ? { duration: 0 } : SPRING_LAYOUT;
const revealed = (inView || reduce) && !loading;
const fade = plotWidth > 0 ? Math.min(32, plotWidth * 0.08) / plotWidth : 0;
const crossX = active === null ? AXIS : x(active);
return (
<div className={cn("min-w-0 select-none", className)}>
<div ref={ref} className="relative w-full" style={{ height }}>
{width > 0 ? (
<>
<svg
aria-hidden="true"
width={width}
height={height}
className="absolute inset-0 overflow-visible"
>
<defs>
<clipPath id={ids.clip}>
<motion.rect
x={0}
y={-16}
width={width}
height={height + 32}
style={{ originX: 0 }}
initial={reduce ? false : { scaleX: 0 }}
animate={{ scaleX: revealed || reduce ? 1 : 0 }}
// Wait for the placeholder to leave before drawing in, and
// collapse only after the lines have faded on the way back.
transition={
revealed
? { duration: 0.7, ease: EASE_OUT, delay: 0.12 }
: { duration: 0, delay: 0.16 }
}
/>
</clipPath>
<linearGradient
id={ids.edges}
gradientUnits="userSpaceOnUse"
x1={AXIS}
x2={AXIS + plotWidth}
y1={0}
y2={0}
>
<stop offset={0} stopColor="white" stopOpacity={fadeEdges ? 0 : 1} />
<stop offset={fade} stopColor="white" />
<stop offset={1 - fade} stopColor="white" />
<stop offset={1} stopColor="white" stopOpacity={fadeEdges ? 0 : 1} />
</linearGradient>
<mask
id={ids.mask}
maskUnits="userSpaceOnUse"
x={0}
y={-16}
width={width}
height={height + 32}
>
<rect x={0} y={-16} width={width} height={height + 32} fill={`url(#${ids.edges})`} />
<motion.rect
x={0}
y={-16}
width={width}
height={height + 32}
fill="black"
initial={{ x: crossX, opacity: 0 }}
animate={{ x: crossX, opacity: inspecting ? RECEDE : 0 }}
transition={{ x: spring, opacity: { duration: 0.18, ease: EASE_OUT } }}
/>
</mask>
<linearGradient
id={ids.crosshair}
gradientUnits="userSpaceOnUse"
x1={0}
x2={0}
y1={PAD.top}
y2={height - PAD.bottom}
>
<stop offset={0} stopColor="var(--foreground)" stopOpacity={0} />
<stop offset={0.25} stopColor="var(--foreground)" stopOpacity={0.35} />
<stop offset={0.75} stopColor="var(--foreground)" stopOpacity={0.35} />
<stop offset={1} stopColor="var(--foreground)" stopOpacity={0} />
</linearGradient>
</defs>
{showGrid ? (
<XYGridLines
ticks={domain.ticks}
position={y}
axis="y"
from={AXIS}
to={AXIS + plotWidth}
reduce={reduce}
/>
) : null}
{baselinePoint ? (
<g mask={`url(#${ids.mask})`}>
<motion.g
clipPath={`url(#${ids.clip})`}
initial={false}
animate={{ opacity: loading ? 0 : 1 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
{rows.map((row) => (
<Series
key={row.id}
row={row}
points={row.values.map((value, i) =>
value === null
? null
: (points.get(pointKey(row.id, categories[i])) ?? null),
)}
baseline={baselinePoint.y}
curve={curve}
area={variant === "area"}
// Overlapping fills compound; keep several series from muddying into one slab.
fillOpacity={rows.length > 1 ? 0.14 : 0.28}
strokeWidth={strokeWidth}
hidden={hidden.includes(row.id)}
dimmed={highlight !== null && highlight !== row.id}
/>
))}
</motion.g>
</g>
) : null}
<AnimatePresence>
{loading ? (
<Placeholder
key="placeholder"
ids={ids}
left={AXIS}
width={plotWidth}
top={PAD.top}
height={innerHeight}
strokeWidth={strokeWidth}
reduce={reduce}
/>
) : null}
</AnimatePresence>
{inspecting && active !== null ? (
<>
<motion.line
y1={PAD.top}
y2={height - PAD.bottom}
x1={0}
x2={0}
stroke={`url(#${ids.crosshair})`}
initial={{ x: x(active), opacity: 0 }}
animate={{ x: x(active), opacity: 1 }}
transition={{ x: spring, opacity: { duration: 0.12 } }}
/>
{activeValues.map(({ row, value }) => (
<motion.g
key={row.id}
initial={{ x: x(active), y: y(value), opacity: 0, scale: 0.5 }}
animate={{ x: x(active), y: y(value), opacity: 1, scale: 1 }}
transition={{ default: spring, opacity: { duration: 0.12 } }}
>
<circle r={10} fill={row.color} opacity={0.18} />
<circle r={4} fill={row.color} stroke="var(--background)" strokeWidth={2} />
</motion.g>
))}
</>
) : null}
</svg>
<motion.div
className="pointer-events-none absolute inset-0"
initial={false}
animate={{ opacity: loading ? 0 : 1 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
<XYTickLabels
ticks={domain.ticks}
position={y}
axis="y"
format={formatTick}
reduce={reduce}
offset={AXIS}
/>
</motion.div>
</>
) : null}
<AnimatePresence>
{loading ? (
<motion.output
key="loading"
className="pointer-events-none absolute flex items-center justify-center text-xs text-muted-foreground"
style={{ left: AXIS, right: PAD.right, top: PAD.top, bottom: PAD.bottom }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
y: reduce ? 0 : 8,
transition: { duration: 0.18, ease: EASE_OUT },
}}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
{loadingLabel ? (
<TextShimmer>{loadingLabel}</TextShimmer>
) : (
<span className="sr-only">Loading</span>
)}
</motion.output>
) : null}
</AnimatePresence>
<div
ref={setOverlay}
className="absolute rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-ring"
style={{ left: AXIS, right: PAD.right, top: PAD.top, bottom: PAD.bottom }}
>
{loading ? null : (
<XYInspector
count={n}
activeIndex={activeIndex}
onActive={(i) => setActive(categories[i])}
label={`${label}: inspect values`}
valueText={(i) =>
[
categories[i],
...visibleRows.map((row) => {
const value = row.values[i];
return `${row.name} ${value === null ? "no data" : formatValue(value)}`;
}),
].join(". ")
}
mode="point"
open={open}
setOpen={setOpen}
onTouchChange={setTouching}
tooltipId={tooltipId}
canHover={canHover}
className="inset-0 size-full"
/>
)}
</div>
</div>
<motion.div
aria-hidden="true"
className="relative mt-2 h-5"
initial={false}
animate={{ opacity: loading ? 0 : 1 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
{width > 0
? categories.map((category, i) =>
(n - 1 - i) % stride === 0 ? (
<span
key={category}
className="absolute top-0.5 -translate-x-1/2 text-[11px] whitespace-nowrap text-muted-foreground transition-opacity duration-150"
style={{
left: x(i),
opacity: inspecting && Math.abs(x(i) - crossX) < PILL_CLEAR ? 0 : 1,
}}
>
{category}
</span>
) : null,
)
: null}
<AnimatePresence>
{inspecting && active !== null ? (
<motion.div
key="pill"
className="pointer-events-none absolute top-0 left-0"
initial={{ x: x(active), opacity: 0 }}
animate={{ x: x(active), opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }}
transition={{ x: spring, opacity: { duration: 0.12 } }}
>
<span className="block -translate-x-1/2 overflow-hidden rounded-full bg-foreground px-2 py-0.5 text-[11px] leading-4 font-medium whitespace-nowrap text-background">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={categories[active]}
className="block"
initial={{ y: reduce ? 0 : direction * 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: reduce ? 0 : direction * -10, opacity: 0 }}
transition={reduce ? { duration: 0.12 } : SPRING_SWAP}
>
{categories[active]}
</motion.span>
</AnimatePresence>
</span>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
</div>
);
}
function Placeholder({
ids,
left,
width,
top,
height,
strokeWidth,
reduce,
}: {
ids: { pulse: string; band: string; mask: string };
left: number;
width: number;
top: number;
height: number;
strokeWidth: number;
reduce: boolean;
}) {
// A calm, slightly rising wave: reads as "a line goes here", not as data.
const d = linePath(
Array.from({ length: PLACEHOLDER_SAMPLES }, (_, i) => {
const t = i / (PLACEHOLDER_SAMPLES - 1);
return {
x: left + t * width,
y: top + height * (0.62 - t * 0.18 + Math.sin(t * Math.PI * 3) * 0.07),
};
}),
"monotone",
);
const band = Math.max(80, width * 0.28);
return (
<motion.g
className="text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
<defs>
<linearGradient id={ids.band} x1="0" x2="1" y1="0" y2="0">
<stop offset={0} stopColor="white" stopOpacity={0} />
<stop offset={0.5} stopColor="white" />
<stop offset={1} stopColor="white" stopOpacity={0} />
</linearGradient>
<mask id={ids.pulse} maskUnits="userSpaceOnUse" x={left - band} y={0} width={width + band * 2} height={top * 2 + height}>
<motion.rect
x={0}
y={0}
width={band}
height={top * 2 + height}
fill={`url(#${ids.band})`}
initial={{ x: left - band }}
animate={{ x: left + width }}
transition={{ duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity, repeatDelay: 0.35 }}
/>
</mask>
</defs>
{/* Shares the plot's edge fade, so the handoff to real lines keeps its silhouette. */}
<g mask={`url(#${ids.mask})`}>
<motion.path
d={d}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
initial={{ strokeOpacity: 0.22 }}
animate={reduce ? { strokeOpacity: [0.22, 0.45, 0.22] } : { strokeOpacity: 0.22 }}
transition={reduce ? { duration: 1.8, ease: EASE_IN_OUT, repeat: Infinity } : undefined}
/>
{reduce ? null : (
<path
d={d}
fill="none"
stroke="currentColor"
strokeOpacity={0.75}
strokeWidth={strokeWidth}
strokeLinecap="round"
mask={`url(#${ids.pulse})`}
/>
)}
</g>
</motion.g>
);
}
function Series({
row,
points,
baseline,
curve,
area,
fillOpacity,
strokeWidth,
hidden,
dimmed,
}: {
row: XYRow;
points: (XYAnimatedPoint | null)[];
baseline: MotionValue<number>;
curve: XYCurve;
area: boolean;
fillOpacity: number;
strokeWidth: number;
hidden: boolean;
dimmed: boolean;
}) {
const gradientId = useId();
const values = [baseline, ...points.flatMap((point) => (point ? [point.x, point.y] : []))];
const read = (coordinates: number[]) => {
let cursor = 1;
return points.map((point) =>
point ? { x: coordinates[cursor++], y: coordinates[cursor++] } : null,
);
};
// SVG geometry has to reshape with the data; no transform of the whole path can express it.
const line = useTransform(values, (coordinates: number[]) => linePath(read(coordinates), curve));
const fill = useTransform(values, (coordinates: number[]) =>
areaPath(read(coordinates), coordinates[0], curve),
);
return (
<motion.g
initial={false}
animate={{ opacity: hidden ? 0 : dimmed ? 0.2 : 1 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
>
{area ? (
<>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={row.color} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={row.color} stopOpacity={0} />
</linearGradient>
</defs>
<motion.path d={fill} fill={`url(#${gradientId})`} />
</>
) : null}
<motion.path
d={line}
fill="none"
stroke={row.color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.g>
);
}
"use client";
import { AnimatePresence, motion } from "motion/react";
import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { TooltipSurface } from "@/components/motion/tooltip-surface";
import { cn } from "@/lib/utils";
import { XYTooltipContent, type XYTooltipRow } from "../xy/tooltip-content";
import { useLineChart } from "./context";
export interface LineChartTooltipData {
category: string;
index: number;
rows: XYTooltipRow[];
}
export interface LineChartTooltipProps {
children?: ReactNode | ((data: LineChartTooltipData) => ReactNode);
className?: string;
/**
* `auto` sits right of the point, flips left near the right edge, and on
* plots too narrow for either moves above or below the point instead.
*/
side?: "auto" | "left" | "right";
}
const GAP = 16;
/** The panel may extend this far into the axis gutters beside the plot, in px. */
const BLEED = { left: 40, right: 12 } as const;
// Softer than the crosshair's layout spring on purpose: the panel trails a
// beat behind the point it describes instead of moving rigidly with it.
const FOLLOW = { type: "spring", stiffness: 260, damping: 28, mass: 0.7 } as const;
/**
* A panel inside the plot that springs after the inspected point, beside its
* highest visible value, so it never covers the dots it describes.
*/
export function LineChartTooltip({ children, className, side = "auto" }: LineChartTooltipProps) {
const {
categories,
visibleRows,
domain,
activeIndex,
open,
loading,
overlay,
geometry,
tooltipId,
formatValue,
reduce,
touching,
} = useLineChart();
const panelRef = useRef<HTMLDivElement>(null);
// Kept between openings: the panel's size barely changes from one point to the next.
const [size, setSize] = useState({ width: 224, height: 96 });
const category = loading ? undefined : categories[activeIndex];
const visible = open && category !== undefined;
useLayoutEffect(() => {
const node = panelRef.current;
if (!visible || !node) return;
const update = () =>
setSize((previous) =>
previous.width === node.offsetWidth && previous.height === node.offsetHeight
? previous
: { width: node.offsetWidth, height: node.offsetHeight },
);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, [visible]);
if (!overlay) return null;
const rows: XYTooltipRow[] = visibleRows.map((row) => ({
id: row.id,
name: row.name,
color: row.color,
value: row.values[activeIndex] ?? null,
}));
const values = rows.flatMap((row) => (row.value === null ? [] : [row.value]));
const top = values.length ? Math.max(...values) : domain.max;
const n = categories.length;
const { width: W, height: H } = geometry;
const px = (n === 1 ? 0.5 : Math.max(activeIndex, 0) / (n - 1)) * W;
const py = ((domain.max - top) / (domain.max - domain.min || 1)) * H;
const minX = -BLEED.left;
const maxX = W + BLEED.right;
const fitsRight = px + GAP + size.width <= maxX;
const fitsLeft = px - GAP - size.width >= minX;
const placement =
side === "right" || (side === "auto" && fitsRight)
? "right"
: side === "left" || (side === "auto" && fitsLeft)
? "left"
: py - GAP - size.height >= 0
? "above"
: "below";
const clampX = (value: number) => Math.min(Math.max(value, minX), maxX - size.width);
const clampY = (value: number) => Math.min(Math.max(value, -8), Math.max(-8, H + 8 - size.height));
const position =
placement === "right"
? { x: px + GAP, y: clampY(py - size.height / 2) }
: placement === "left"
? { x: px - GAP - size.width, y: clampY(py - size.height / 2) }
: {
x: clampX(px - size.width / 2),
y: placement === "above" ? py - GAP - size.height : py + GAP,
};
const origin = {
right: "left center",
left: "right center",
above: "bottom center",
below: "top center",
}[placement];
return createPortal(
<AnimatePresence>
{visible ? (
<motion.div
key="tooltip"
ref={panelRef}
className="pointer-events-none absolute top-0 left-0 z-10 w-max"
initial={position}
animate={position}
// The trailing follow suits a mouse; under a finger it reads as lag.
transition={reduce || touching ? { duration: 0 } : FOLLOW}
>
<TooltipSurface
id={tooltipId}
style={{ "--tooltip-origin": origin } as CSSProperties}
className={cn("max-w-[min(20rem,calc(100vw-2rem))] py-2", className)}
>
{typeof children === "function"
? children({ category, index: activeIndex, rows })
: (children ?? (
<XYTooltipContent title={category} rows={rows} formatValue={formatValue} />
))}
</TooltipSurface>
</motion.div>
) : null}
</AnimatePresence>,
overlay,
);
}
import type { XYRow } from "./model";
/** Every value stays readable by screen readers without a tab stop per mark. */
export function XYDataTable({
label,
rows,
categories,
formatValue,
}: {
label: string;
rows: readonly XYRow[];
categories: readonly string[];
formatValue: (value: number) => string;
}) {
return (
<div className="sr-only">
<table>
<caption>{label} data</caption>
<thead>
<tr>
<th scope="col">Category</th>
{rows.map((row) => (
<th key={row.id} scope="col">
{row.name}
</th>
))}
</tr>
</thead>
<tbody>
{categories.map((category, i) => (
<tr key={category}>
<th scope="row">{category}</th>
{rows.map((row) => {
const value = row.values[i];
return <td key={row.id}>{value === null ? "No data" : formatValue(value)}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
export interface XYSeries {
/** Stable, unique identity. Keep it when updating values so marks morph in place. */
id: string;
name: string;
/** Any CSS color, including theme variables. */
color: string;
/** Values aligned with categories. Missing or non-finite values leave a gap. */
values: readonly (number | null)[];
}
export type XYRow = Omit<XYSeries, "values"> & { values: (number | null)[] };
/** First of repeated series and category ids wins. Invalid values become gaps, never zeros. */
export function normalizeXY(
series: readonly XYSeries[],
categories: readonly string[],
{ nonnegative = false }: { nonnegative?: boolean } = {},
) {
const columns: { id: string; source: number }[] = [];
const seenColumns = new Set<string>();
categories.forEach((id, source) => {
if (seenColumns.has(id)) return;
seenColumns.add(id);
columns.push({ id, source });
});
const seenRows = new Set<string>();
const rows: XYRow[] = [];
for (const { values, ...row } of series) {
if (seenRows.has(row.id)) continue;
seenRows.add(row.id);
rows.push({
...row,
values: columns.map(({ source }) => {
const value = values[source];
return typeof value === "number" && Number.isFinite(value) && (!nonnegative || value >= 0)
? value
: null;
}),
});
}
return { rows, categories: columns.map((column) => column.id) };
}
export type XYCurve = "monotone" | "linear" | "step";
export type XYPathPoint = { x: number; y: number } | null;
const f = (value: number) => Math.round(value * 100) / 100;
/** Contiguous runs. A gap in the data is a gap in the line, never an interpolation. */
function runs(points: readonly XYPathPoint[]) {
const out: { x: number; y: number }[][] = [];
let run: { x: number; y: number }[] = [];
for (const point of points) {
if (point) run.push(point);
else if (run.length) {
out.push(run);
run = [];
}
}
if (run.length) out.push(run);
return out;
}
// Monotone cubic interpolation (Fritsch–Carlson): smooth, but it never
// overshoots a local peak or dips below a local trough the data doesn't have.
function tangents(run: { x: number; y: number }[]) {
const n = run.length;
const t = new Array<number>(n).fill(0);
for (let i = 1; i < n - 1; i++) {
const h0 = run[i].x - run[i - 1].x;
const h1 = run[i + 1].x - run[i].x;
const s0 = h0 ? (run[i].y - run[i - 1].y) / h0 : 0;
const s1 = h1 ? (run[i + 1].y - run[i].y) / h1 : 0;
const p = h0 + h1 ? (s0 * h1 + s1 * h0) / (h0 + h1) : 0;
t[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p));
}
const end = (a: number, b: number, neighbour: number) => {
const h = run[b].x - run[a].x;
return h ? (3 * (run[b].y - run[a].y)) / h / 2 - neighbour / 2 : neighbour;
};
t[0] = end(0, 1, t[1]);
t[n - 1] = end(n - 2, n - 1, t[n - 2]);
return t;
}
function segment(run: { x: number; y: number }[], curve: XYCurve) {
const [first, ...rest] = run;
let d = `M${f(first.x)},${f(first.y)}`;
// A lone sample draws as a dot with a round line cap.
if (!rest.length) return `${d}h0`;
if (curve === "linear" || run.length === 2) {
for (const p of rest) d += `L${f(p.x)},${f(p.y)}`;
return d;
}
if (curve === "step") {
for (let i = 1; i < run.length; i++) {
const mid = (run[i - 1].x + run[i].x) / 2;
d += `H${f(mid)}V${f(run[i].y)}`;
}
return `${d}H${f(run[run.length - 1].x)}`;
}
const t = tangents(run);
for (let i = 1; i < run.length; i++) {
const a = run[i - 1];
const b = run[i];
const dx = (b.x - a.x) / 3;
d += `C${f(a.x + dx)},${f(a.y + dx * t[i - 1])},${f(b.x - dx)},${f(b.y - dx * t[i])},${f(b.x)},${f(b.y)}`;
}
return d;
}
export function linePath(points: readonly XYPathPoint[], curve: XYCurve = "monotone") {
return runs(points)
.map((run) => segment(run, curve))
.join("");
}
/** Close each run down to the baseline. Lone samples have no area to fill. */
export function areaPath(
points: readonly XYPathPoint[],
baseline: number,
curve: XYCurve = "monotone",
) {
return runs(points)
.filter((run) => run.length > 1)
.map(
(run) =>
`${segment(run, curve)}L${f(run[run.length - 1].x)},${f(baseline)}L${f(run[0].x)},${f(baseline)}Z`,
)
.join("");
}
export interface XYDomain {
min: number;
max: number;
ticks: number[];
}
const tidy = (value: number) => Number(value.toPrecision(12));
function niceStep(raw: number) {
const power = 10 ** Math.floor(Math.log10(raw));
const fraction = raw / power;
return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power;
}
/** Round a value range out to human tick steps (1, 2, 5 × 10ⁿ). */
export function niceDomain(
values: readonly number[],
{ includeZero = false, count = 4, fixed }: {
includeZero?: boolean;
count?: number;
fixed?: readonly [number, number];
} = {},
): XYDomain {
let min = fixed?.[0] ?? (values.length ? Math.min(...values) : 0);
let max = fixed?.[1] ?? (values.length ? Math.max(...values) : 1);
if (includeZero && !fixed) {
min = Math.min(0, min);
max = Math.max(0, max);
}
if (min === max) {
if (min === 0) max = 1;
else if (includeZero && min > 0) min = 0;
else {
const pad = Math.abs(min) * 0.1;
min -= pad;
max += pad;
}
}
const step = niceStep((max - min) / count);
const lo = fixed ? min : tidy(Math.floor(min / step) * step);
const hi = fixed ? max : tidy(Math.ceil(max / step) * step);
const ticks: number[] = [];
for (let tick = Math.ceil(lo / step) * step; tick <= hi + step / 1e6; tick += step)
ticks.push(tidy(tick));
return { min: lo, max: hi, ticks };
}
/** Show every nth label so neighbours never overlap. */
export function labelStride(count: number, length: number, minGap = 64) {
if (count <= 1 || length <= 0) return 1;
return Math.max(1, Math.ceil((count * minGap) / length));
}
"use client";
import { useCallback, useId, useLayoutEffect, useRef, useState } from "react";
/** Controlled/uncontrolled value with a stable setter. */
export function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (next: T) => void) {
const [internal, setInternal] = useState(defaultValue);
const latest = useRef({ value, internal, onChange });
useLayoutEffect(() => {
latest.current = { value, internal, onChange };
});
const set = useCallback((next: T) => {
const current = latest.current;
if (current.value === undefined) setInternal(next);
if ((current.value === undefined ? current.internal : current.value) !== next)
current.onChange?.(next);
}, []);
return [value === undefined ? internal : value, set, setInternal] as const;
}
/**
* The inspected category is held by id and resolved during render, so a
* category that leaves the data clears the cursor instead of landing on a
* neighbour, and one that comes back cannot revive it.
*/
export function useInspection({
ids,
activeId,
defaultActiveId = null,
onActiveChange,
}: {
ids: readonly string[];
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string | null) => void;
}) {
const [requested, setActive, setInternal] = useControllable(
activeId,
defaultActiveId,
onActiveChange,
);
if (activeId === undefined && requested !== null && !ids.includes(requested)) setInternal(null);
const activeIndex = requested === null ? -1 : ids.indexOf(requested);
const [open, setOpen] = useState(false);
// A finger on the plot: inspection marks follow it directly instead of gliding.
const [touching, setTouching] = useState(false);
const plotRef = useRef<HTMLDivElement>(null);
const tooltipId = useId();
return {
activeId: activeIndex >= 0 ? requested : null,
activeIndex,
setActive,
open,
setOpen,
touching,
setTouching,
plotRef,
tooltipId,
};
}
"use client";
import { cn } from "@/lib/utils";
import type { XYRow } from "./model";
export interface XYLegendProps {
rows: readonly XYRow[];
hidden: readonly string[];
onToggle: (id: string) => void;
onHover: (id: string | null) => void;
canHover: boolean;
label: string;
className?: string;
}
/** Each entry is a toggle: pressed means the series is drawn. */
export function XYLegend({
rows,
hidden,
onToggle,
onHover,
canHover,
label,
className,
}: XYLegendProps) {
return (
<ul
aria-label={label}
className={cn("flex flex-wrap items-center gap-x-1 gap-y-1 text-xs", className)}
>
{rows.map((row) => {
const shown = !hidden.includes(row.id);
return (
<li key={row.id}>
<button
type="button"
aria-pressed={shown}
onClick={() => onToggle(row.id)}
onPointerEnter={() => {
if (canHover && shown) onHover(row.id);
}}
onPointerLeave={() => onHover(null)}
onFocus={() => {
if (shown) onHover(row.id);
}}
onBlur={() => onHover(null)}
className={cn(
"flex min-h-9 items-center gap-2 rounded-md px-2 text-muted-foreground transition-[color,opacity] duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
!shown && "opacity-50",
)}
>
<span
aria-hidden="true"
className={cn(
"size-2.5 shrink-0 rounded-full border-2 transition-colors duration-150",
)}
style={{ borderColor: row.color, backgroundColor: shown ? row.color : "transparent" }}
/>
<span className={cn(!shown && "line-through decoration-muted-foreground/60")}>
{row.name}
</span>
</button>
</li>
);
})}
</ul>
);
}
"use client";
import { AnimatePresence, motion } from "motion/react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
const FADE = { duration: 0.16, ease: EASE_OUT } as const;
const EXIT = { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } } as const;
/**
* Value gridlines keyed by tick value: a tick that survives a rescale glides to
* its new position, new ticks fade in where they belong, old ones fade out.
*/
export function XYGridLines({
ticks,
position,
axis,
from,
to,
reduce,
}: {
ticks: readonly number[];
position: (value: number) => number;
/** `y` draws horizontal lines at y positions; `x` vertical lines at x positions. */
axis: "x" | "y";
from: number;
to: number;
reduce: boolean;
}) {
return (
<AnimatePresence initial={false}>
{ticks.map((tick) => {
const at = { [axis]: position(tick) };
return (
<motion.line
key={tick}
{...(axis === "y" ? { x1: from, x2: to, y1: 0, y2: 0 } : { y1: from, y2: to, x1: 0, x2: 0 })}
// Border tokens are tuned for card edges and vanish as thin rules on a plot.
stroke="currentColor"
strokeOpacity={tick === 0 ? 0.3 : 0.12}
className="text-muted-foreground"
initial={{ opacity: 0, ...at }}
animate={{ opacity: 1, ...at }}
exit={EXIT}
transition={{ opacity: FADE, default: reduce ? { duration: 0 } : SPRING_LAYOUT }}
/>
);
})}
</AnimatePresence>
);
}
export function XYTickLabels({
ticks,
position,
axis,
format,
reduce,
offset,
}: {
ticks: readonly number[];
position: (value: number) => number;
/** `y` stacks labels in a left gutter; `x` lines them up under the plot. */
axis: "x" | "y";
format: (value: number) => string;
reduce: boolean;
/** Gutter width for `y`, or the top edge of the label row for `x`, in px. */
offset: number;
}) {
return (
<AnimatePresence initial={false}>
{ticks.map((tick) => {
const at = { [axis]: position(tick) };
return (
<motion.span
key={tick}
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 font-mono text-[11px] whitespace-nowrap text-muted-foreground tabular-nums"
style={axis === "y" ? { width: offset - 8 } : { top: offset }}
initial={{ opacity: 0, ...at }}
animate={{ opacity: 1, ...at }}
exit={EXIT}
transition={{ opacity: FADE, default: reduce ? { duration: 0 } : SPRING_LAYOUT }}
>
<span
className={
axis === "y" ? "block -translate-y-1/2 text-right" : "block -translate-x-1/2"
}
>
{format(tick)}
</span>
</motion.span>
);
})}
</AnimatePresence>
);
}
"use client";
import { type CSSProperties, type PointerEvent, useRef } from "react";
import { cn } from "@/lib/utils";
export interface XYInspectorProps {
count: number;
activeIndex: number;
onActive: (index: number) => void;
/** Accessible name of the slider. */
label: string;
valueText: (index: number) => string;
/** `point` snaps to the nearest sample (lines); `band` to the slot under the pointer (bars). */
mode: "point" | "band";
/** `vertical` reads categories top to bottom, for horizontal bars. */
orientation?: "horizontal" | "vertical";
open: boolean;
setOpen: (open: boolean) => void;
tooltipId?: string;
canHover: boolean;
/** Reports a touch drag starting and ending, so marks can track the finger 1:1. */
onTouchChange?: (touching: boolean) => void;
className?: string;
style?: CSSProperties;
}
/**
* One invisible native range over the plot: a single tab stop for keyboard and
* assistive technology, with pointer hit testing that matches the drawn marks.
*/
export function XYInspector({
count,
activeIndex,
onActive,
label,
valueText,
mode,
orientation = "horizontal",
open,
setOpen,
tooltipId,
canHover,
onTouchChange,
className,
style,
}: XYInspectorProps) {
const pointerDriven = useRef(false);
const focused = useRef(false);
// Focus from a mouse click is incidental: it must not pin the tooltip open
// once the pointer leaves. Focus from the keyboard (or keys pressed after a
// click) is deliberate and keeps it open until blur or Escape.
const mouseFocus = useRef(false);
const last = useRef<number | null>(null);
const index = activeIndex >= 0 ? activeIndex : count - 1;
const vertical = orientation === "vertical";
const inspectPointer = (event: PointerEvent<HTMLInputElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const length = vertical ? bounds.height : bounds.width;
if (!length || !count) return;
const offset = vertical ? event.clientY - bounds.top : event.clientX - bounds.left;
const fraction = Math.min(1, Math.max(0, offset / length));
const next =
mode === "point"
? Math.round(fraction * (count - 1))
: Math.min(count - 1, Math.floor(fraction * count));
onActive(next);
if (last.current !== next) setOpen(true);
last.current = next;
};
const step = (next: number) => {
onActive(Math.min(count - 1, Math.max(0, next)));
setOpen(true);
};
if (!count) return null;
return (
<input
type="range"
aria-label={label}
aria-orientation={orientation}
min={0}
max={count - 1}
step={1}
value={index}
aria-valuetext={valueText(index)}
aria-describedby={open && activeIndex >= 0 ? tooltipId : undefined}
className={cn("absolute m-0 cursor-crosshair touch-pan-y opacity-0", className)}
style={style}
onFocus={() => {
focused.current = true;
if (!pointerDriven.current) step(index);
}}
onBlur={() => {
focused.current = false;
mouseFocus.current = false;
pointerDriven.current = false;
setOpen(false);
}}
onPointerEnter={(event) => {
if (canHover && event.pointerType !== "touch") inspectPointer(event);
}}
onPointerMove={(event) => {
if (event.pointerType === "touch" ? event.buttons === 1 : canHover || event.buttons === 1)
inspectPointer(event);
}}
onPointerLeave={(event) => {
last.current = null;
// Touch fires a leave after every tap; tap-to-inspect stays open.
const mouse = event.pointerType !== "touch" && mouseFocus.current;
if (!focused.current || pointerDriven.current || mouse) setOpen(false);
}}
onPointerDown={(event) => {
pointerDriven.current = true;
if (event.pointerType === "touch") onTouchChange?.(true);
else mouseFocus.current = true;
last.current = null;
inspectPointer(event);
}}
onPointerUp={(event) => {
if (event.pointerType === "touch") onTouchChange?.(false);
}}
onPointerCancel={(event) => {
pointerDriven.current = false;
if (event.pointerType === "touch") onTouchChange?.(false);
setOpen(false);
}}
onClick={() => {
// Native range changes finish before click. Release the pointer guard
// so later assistive-technology changes are accepted.
pointerDriven.current = false;
}}
onKeyDown={(event) => {
pointerDriven.current = false;
mouseFocus.current = false;
if (event.key === "Escape") setOpen(false);
else if (vertical && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
// Categories read top to bottom, so Down moves down the list.
event.preventDefault();
step(index + (event.key === "ArrowDown" ? 1 : -1));
} else if (
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(
event.key,
)
)
setOpen(true);
}}
onChange={(event) => {
// Native thumb travel differs from the drawn marks. Pointer hit testing
// owns pointer gestures; the range owns keyboard and AT input.
if (pointerDriven.current) return;
step(Number(event.target.value));
}}
/>
);
}
"use client";
import { animate, type MotionValue, motionValue } from "motion/react";
import { useLayoutEffect, useRef, useState } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
export type XYAnimatedPoint = { x: MotionValue<number>; y: MotionValue<number> };
export type XYPointTarget = { key: string; x: number; y: number };
/**
* One spring per coordinate, keyed by identity. Paths, dots and fills read the
* same values, so a data change morphs every mark together. A resize jumps:
* the data didn't change, so nothing should appear to move.
*/
export function useAnimatedPoints(targets: XYPointTarget[], reduce: boolean, width: number) {
const shape = targets.map((target) => target.key).join("\n");
const create = (previous?: Map<string, XYAnimatedPoint>) =>
new Map(
targets.map((target) => [
target.key,
previous?.get(target.key) ?? { x: motionValue(target.x), y: motionValue(target.y) },
]),
);
const [stored, setStored] = useState(() => ({ shape, points: create() }));
let points = stored.points;
if (stored.shape !== shape) {
points = create(stored.points);
setStored({ shape, points });
}
const lastWidth = useRef(width);
// Hover and focus renders produce the same snapshot, so they never restart a spring.
const snapshot = JSON.stringify(targets);
useLayoutEffect(() => {
const resized = lastWidth.current !== width;
lastWidth.current = width;
const next: XYPointTarget[] = JSON.parse(snapshot);
const controls: ReturnType<typeof animate>[] = [];
for (const target of next) {
const point = points.get(target.key);
if (!point) continue;
for (const axis of ["x", "y"] as const) {
if (reduce || resized) point[axis].jump(target[axis]);
else controls.push(animate(point[axis], target[axis], SPRING_LAYOUT));
}
}
return () => {
for (const control of controls) control.stop();
};
}, [snapshot, reduce, points, width]);
return points;
}
"use client";
import { useLayoutEffect, useRef, useState } from "react";
/** Pixel geometry keeps dots round and strokes even; nothing renders before the first measure. */
export function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const update = () => setWidth(node.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
return { ref, width };
}
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{TEXT_SHIMMER_KEYFRAMES}
</style>
<Comp
style={textShimmerStyle(duration)}
className={cn(
"inline-block",
TEXT_SHIMMER_CLASS_NAME,
className,
)}
>
{children}
</Comp>
</>
);
}
"use client";
import { MotionConfig } from "motion/react";
import { NumberTicker } from "@/components/motion/number-ticker";
import { cn } from "@/lib/utils";
export interface XYTooltipRow {
id: string;
name: string;
color: string;
value: number | null;
}
function Value({ value, format }: { value: number; format: (value: number) => string }) {
return (
// Keep the short digit tween when composed inside spring-based Tabs.
<MotionConfig transition={{ type: "tween" }}>
<NumberTicker
value={value}
// Keep the consumer's exact formatting, including fractions and units.
format={() => format(value)}
startOnView={false}
duration={0.2}
stagger={0}
className="whitespace-pre"
/>
</MotionConfig>
);
}
export function XYTooltipContent({
title,
rows,
formatValue,
total,
compact = false,
className,
}: {
title: string;
rows: readonly XYTooltipRow[];
formatValue: (value: number) => string;
total?: number | null;
/** Size to the content instead of a fixed panel width, for pointer-following readouts. */
compact?: boolean;
className?: string;
}) {
return (
<span className={cn("block max-w-full min-w-0", compact ? "min-w-36" : "w-56", className)}>
<span className={cn("block text-xs font-medium", compact ? "mb-1.5" : "mb-2")}>{title}</span>
<span className={cn("grid", compact ? "gap-1" : "gap-1.5")}>
{rows.map((row) => (
<span key={row.id} className="flex items-center gap-2 text-[11px]">
<span
aria-hidden="true"
className="h-2.5 w-1 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="min-w-0 flex-1 truncate pr-3 text-muted-foreground">{row.name}</span>
<span className="shrink-0 font-mono tabular-nums">
{row.value === null ? "No data" : <Value value={row.value} format={formatValue} />}
</span>
</span>
))}
{total !== undefined && total !== null ? (
<span className="mt-0.5 flex items-center gap-2 border-t border-border pt-1.5 text-[11px]">
<span className="flex-1 text-muted-foreground">Total</span>
<span className="font-mono font-medium tabular-nums">
<Value value={total} format={formatValue} />
</span>
</span>
) : null}
</span>
</span>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import type { ComponentProps, ReactNode, Ref } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
/** Presentation only: the unanimated parent owns measurement and positioning. */
export function TooltipSurface({
children,
side: _side = "top",
className,
ref,
ready = true,
style,
...props
}: Omit<ComponentProps<typeof motion.span>, "children"> & {
children?: ReactNode;
/** Start the entrance only after the positioning layer has been measured. */
ready?: boolean;
side?: "top" | "right" | "bottom" | "left";
ref?: Ref<HTMLSpanElement>;
}) {
const reduce = useReducedMotion();
const closed = { opacity: 0, scale: reduce ? 1 : 0.94 };
return (
<motion.span
ref={ref}
role="tooltip"
initial={closed}
animate={{
...(ready ? { opacity: 1, scale: 1 } : closed),
transition: { duration: 0.18, ease: EASE_OUT },
}}
exit={{ ...closed, transition: { duration: 0.12, ease: EASE_OUT } }}
style={{ transformOrigin: "var(--tooltip-origin, center)", ...style }}
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
{...props}
>
{children}
</motion.span>
);
}
"use client";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NumberTickerProps {
value: number;
/** Digits to pad to (left). */
pad?: number;
/**
* Fraction digits to keep. The value is rounded to this many places before
* `format` runs (default 0, whole numbers).
*/
decimals?: number;
/** Per-digit roll duration in seconds. */
duration?: number;
/** Stagger between digits. */
stagger?: number;
/** Render only after the element enters the viewport. */
startOnView?: boolean;
prefix?: string;
suffix?: string;
/** Add a small blur during digit rolls. */
blur?: boolean;
className?: string;
digitClassName?: string;
/** Insert locale group separators (commas). Server-component safe. */
locale?: boolean;
/** Custom formatter. Client-only — server components must use `locale` instead. */
format?: (value: number) => string;
}
const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);
export function NumberTicker({
value,
pad,
decimals = 0,
duration = 0.9,
stagger = 0.04,
startOnView = true,
prefix,
suffix,
blur = false,
className,
digitClassName,
locale,
format,
}: NumberTickerProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const inView = useInView(containerRef, { once: true, amount: 0.6 });
const [armed, setArmed] = useState(!startOnView);
useEffect(() => {
if (startOnView && inView) setArmed(true);
}, [startOnView, inView]);
const text = useMemo(() => {
const places = Math.max(0, Math.floor(decimals));
const factor = 10 ** places;
const rounded = Math.round(value * factor) / factor;
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString(undefined, {
minimumFractionDigits: places,
maximumFractionDigits: places,
})
: rounded.toFixed(places);
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, decimals, format, locale]);
const glyphs = useMemo(() => {
const chars = text.split("");
// Key by place value (position from the right): a changing digit keeps its
// identity and rolls to the new value instead of remounting and replaying
// from 0. Growing numbers add glyphs on the left without re-keying the
// ones, tens, hundreds already on screen.
return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
}, [text]);
const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;
// Stagger is an entrance flourish. Once the reveal has played, value
// changes roll every digit immediately — a per-digit delay on live updates
// reads as lag.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!armed || entered) return;
const total = (duration + glyphs.length * stagger) * 1000;
const t = window.setTimeout(() => setEntered(true), total);
return () => window.clearTimeout(t);
}, [armed, entered, duration, stagger, glyphs.length]);
return (
<span
ref={containerRef}
className={cn("inline-flex items-baseline tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-baseline">
{prefix ? <span>{prefix}</span> : null}
{glyphs.map(({ char, id }, i) => {
const isDigit = /\d/.test(char);
if (!isDigit) {
return (
<span key={id} className="inline-block">
{char}
</span>
);
}
const digit = Number(char);
return (
<Digit
key={id}
digit={armed ? digit : 0}
delay={entered ? 0 : i * stagger}
duration={duration}
blur={blur}
className={digitClassName}
/>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
function Digit({
digit,
delay,
duration,
blur,
className,
}: {
digit: number;
delay: number;
duration: number;
blur: boolean;
className?: string;
}) {
const reduce = useReducedMotion();
const columnRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
return;
}
const node = columnRef.current;
const controls = animate(
node,
{ filter: ["blur(10px)", "blur(0px)"] },
{
duration: Math.min(duration * 0.75, 0.32),
delay,
ease: EASE_OUT,
},
);
return () => {
controls.stop();
node.style.filter = "blur(0px)";
};
}, [blur, delay, digit, duration, reduce]);
return (
// The invisible in-flow digit gives the slot a real text baseline so it
// lines up with surrounding text; clip-path clips the rolling column
// without moving that baseline the way overflow: hidden would.
<span
className={cn(
"relative inline-block text-center [clip-path:inset(0)]",
className,
)}
style={{
height: `${DIGIT_HEIGHT_EM}em`,
lineHeight: `${DIGIT_HEIGHT_EM}em`,
width: "1ch",
}}
>
<span className="invisible">{digit}</span>
<motion.span
ref={columnRef}
initial={{ y: 0 }}
animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
transition={
reduce
? { duration: 0 }
: { duration, delay, ease: EASE_OUT }
}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
>
{DIGITS.map((n) => (
<span
key={n}
className="flex h-[1.1em] items-center justify-center leading-none"
>
{n}
</span>
))}
</motion.span>
</span>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cancelFrame, frame, motion, MotionConfig, useReducedMotion, useSpring, type MotionValue, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useLayoutEffect,
useRef,
useMemo,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
/** Whether trigger icons show on every tab or only on the selected one. */
type IconDisplay = "always" | "selected";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
icons: IconDisplay;
/** The pill's box in the list, for tabs whose width changes as they're selected. */
track: Track;
/** Whether the tracked pill has been measured; until then CSS places it. */
placed: boolean;
setPlaced: (placed: boolean) => void;
/**
* The one tab Tab reaches: the selected tab, or the first enabled one when
* nothing enabled is selected. `null` until the triggers have registered,
* when every enabled tab stays reachable.
*/
tabStop: string | null;
registerTab: (value: string, disabled: boolean) => void;
unregisterTab: (value: string) => void;
/** Values with a mounted panel, so triggers only point at panels that exist. */
panels: ReadonlySet<string>;
registerPanel: (value: string) => void;
unregisterPanel: (value: string) => void;
tabId: (value: string) => string;
panelId: (value: string) => string;
};
type Track = Record<"x" | "y" | "width" | "height", MotionValue<number>>;
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;
}
// Settle without overshoot: a scrollable tab list would turn even a small
// overshoot into a transient scrollbar and layout shift.
// Scale stiffness and damping together for a quicker glide with the same feel.
const transition: Transition = {
type: "spring",
stiffness: 245,
damping: 36,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
icons = "always",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
/**
* With "selected", a trigger's icon springs open only on the selected tab
* and folds away when another is chosen. Defaults to "always".
*/
icons?: IconDisplay;
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 x = useSpring(0, transition);
const y = useSpring(0, transition);
const width = useSpring(0, transition);
const height = useSpring(0, transition);
const track = useMemo(() => ({ x, y, width, height }), [x, y, width, height]);
const [placed, setPlaced] = useState(false);
const [tabs, setTabs] = useState<Map<string, boolean>>(new Map());
const registerTab = useCallback((tab: string, disabled: boolean) => {
setTabs((map) => (map.get(tab) === disabled ? map : new Map(map).set(tab, disabled)));
}, []);
const unregisterTab = useCallback((tab: string) => {
setTabs((map) => {
if (!map.has(tab)) return map;
const next = new Map(map);
next.delete(tab);
return next;
});
}, []);
const [panels, setPanels] = useState<ReadonlySet<string>>(new Set());
const registerPanel = useCallback((panel: string) => {
setPanels((set) => (set.has(panel) ? set : new Set(set).add(panel)));
}, []);
const unregisterPanel = useCallback((panel: string) => {
setPanels((set) => {
if (!set.has(panel)) return set;
const next = new Set(set);
next.delete(panel);
return next;
});
}, []);
// Resolved during render so the list has exactly one tab stop in the same
// commit that changes the selection or the tabs.
let tabStop: string | null = null;
if (tabs.size > 0) {
const enabled = Array.from(tabs).filter(([, disabled]) => !disabled);
tabStop = enabled.some(([tab]) => tab === current) ? current : (enabled[0]?.[0] ?? "");
}
const baseId = useId();
const tabId = useCallback((tab: string) => `${baseId}-tab-${tab.replace(/[^\w-]/g, "_")}`, [baseId]);
const panelId = useCallback((tab: string) => `${baseId}-panel-${tab.replace(/[^\w-]/g, "_")}`, [baseId]);
const contextValue = useMemo(
() => ({
value: current,
setValue,
layoutId,
variant,
icons,
track,
placed,
setPlaced,
tabStop,
registerTab,
unregisterTab,
panels,
registerPanel,
unregisterPanel,
tabId,
panelId,
}),
[current, icons, layoutId, setValue, variant, track, placed, tabStop, registerTab, unregisterTab, panels, registerPanel, unregisterPanel, tabId, panelId],
);
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,
wrapperClassName,
onKeyDown,
...props
}: {
wrapperClassName?: string;
} & HTMLAttributes<HTMLDivElement>) {
const { variant, value, icons, track, placed, setPlaced } = useTabs();
const reduce = useReducedMotion();
const tracked = icons === "selected" && variant !== "underline";
const rootRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const viewportId = useId();
const [edges, setEdges] = useState({ overflow: false, left: false, right: false });
const measure = useCallback(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
if (!root || !viewport) return;
// Overlay controls do not reduce the viewport or change its scroll range.
const overflow = viewport.scrollWidth > root.clientWidth + 1;
const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const rtl = getComputedStyle(viewport).direction === "rtl";
// Modern browsers expose negative scrollLeft in RTL. Clamp rubber-banding.
const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));
const next = { overflow, left: fromLeft > 1, right: fromLeft < max - 1 };
setEdges((previous) => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);
}, []);
const reveal = useCallback((tab: HTMLElement | null) => {
const viewport = viewportRef.current;
if (!viewport || !tab) return;
const frame = viewport.getBoundingClientRect();
const item = tab.getBoundingClientRect();
const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const rtl = getComputedStyle(viewport).direction === "rtl";
const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));
// Keep the selected/focused label clear of the arrows over the faded edges.
const left = frame.left + (fromLeft > 1 ? 36 : 0);
const right = frame.right - (fromLeft < max - 1 ? 36 : 0);
const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;
// Scroll only this viewport; scrollIntoView can also move the whole page.
if (delta) viewport.scrollBy({ left: delta, behavior: reduce ? "instant" : "smooth" });
}, [reduce]);
useLayoutEffect(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
const list = listRef.current;
if (!root || !viewport || !list) return;
const update = () => {
measure();
reveal(list.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]'));
};
const observer = new ResizeObserver(update);
observer.observe(root);
observer.observe(viewport);
observer.observe(list);
viewport.addEventListener("scroll", measure, { passive: true });
update();
return () => {
observer.disconnect();
viewport.removeEventListener("scroll", measure);
};
}, [measure, reveal]);
useLayoutEffect(() => {
// Children may change without a resize; controlled selection must also reveal.
void children;
void value;
void edges.overflow;
measure();
reveal(listRef.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? null);
}, [children, value, edges.overflow, measure, reveal]);
useLayoutEffect(() => {
// With icons="selected" the chosen tab grows as its icon opens and the
// last one shrinks. A layout animation measures the tabs mid-change, so
// the pill would squeeze and catch up. Instead the pill springs straight
// to where the selected tab will settle, with every icon open or closed.
if (!tracked) return;
const list = listRef.current;
if (!list) return;
void children;
const place = () => {
const tabs = Array.from(list.querySelectorAll<HTMLElement>('[role="tab"]'));
const target = tabs.find((tab) => tab.dataset.tabsValue === value)?.parentElement;
if (!target) return;
// Measure the settled layout: open the selected icon and fold the rest
// for one synchronous read, then hand the styles back to Motion before
// anything paints. Exact in any layout, from flex rows to grids.
const icons = Array.from(list.querySelectorAll<HTMLElement>('[role="tab"] > [data-tabs-icon]'));
const saved = icons.map((icon) => [icon.style.width, icon.style.marginRight]);
for (const icon of icons) {
const open = icon.parentElement?.parentElement === target;
icon.style.width = open ? "auto" : "0px";
icon.style.marginRight = open ? `${ICON_GAP}px` : "0px";
}
const box = {
x: target.offsetLeft,
y: target.offsetTop,
// Fractional: a rounded width leaves the pill a hair off its tab.
width: target.getBoundingClientRect().width,
height: target.offsetHeight,
};
icons.forEach((icon, index) => {
[icon.style.width, icon.style.marginRight] = saved[index];
});
// A first measurement, or one after the list was hidden, lands in place.
const instant = reduce || track.width.get() === 0;
for (const key of ["x", "y", "width", "height"] as const) {
if (instant) track[key].jump(box[key]);
else track[key].set(box[key]);
}
// Hand over from the CSS pill once there's a real box, before paint.
if (box.width > 0) setPlaced(true);
};
place();
const observer = new ResizeObserver(place);
for (const tab of list.querySelectorAll('[role="tab"]')) if (tab.parentElement) observer.observe(tab.parentElement);
return () => observer.disconnect();
}, [tracked, value, children, reduce, track, setPlaced]);
useLayoutEffect(() => {
if (variant === "underline") return;
const list = listRef.current;
if (!list) return;
void children;
// `placed` swaps the indicator element, so measure the new one.
void placed;
const labels = Array.from(list.querySelectorAll<HTMLElement>("[data-tabs-label]"));
const indicator = list.querySelector<HTMLElement>("[data-tabs-indicator]");
const target = list.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
if (!indicator || !target || target.dataset.tabsValue !== value) {
for (const label of labels) label.style.clipPath = "inset(0 100% 0 0)";
return;
}
let frames = 0;
let stillFrames = 0;
let previous: { left: number; right: number } | undefined;
const syncClips = () => {
const pill = (reduce ? target : indicator).getBoundingClientRect();
// Read ALL geometry before writing ANY masks. A loop per tab interleaved
// reads and writes, forcing the browser to flush styles repeatedly.
const clips = labels.map((label) => {
const bounds = label.getBoundingClientRect();
const left = Math.max(0, Math.min(bounds.width, pill.left - bounds.left));
const right = Math.max(0, Math.min(bounds.width, bounds.right - pill.right));
return left + right >= bounds.width ? "inset(0 100% 0 0)" : `inset(0 ${right}px 0 ${left}px)`;
});
labels.forEach((label, index) => {
if (label.style.clipPath !== clips[index]) label.style.clipPath = clips[index];
});
frames += 1;
stillFrames = previous && Math.abs(pill.left - previous.left) < 0.01 && Math.abs(pill.right - previous.right) < 0.01 ? stillFrames + 1 : 0;
previous = { left: pill.left, right: pill.right };
if (reduce || (frames > 2 && stillFrames >= 2)) cancelFrame(syncClips);
};
// One shared pass after Motion paints the projected pill keeps every label
// in sync, including labels crossed during a long or interrupted glide.
frame.postRender(syncClips, true);
// A list measured while hidden (display: none, a closed panel, a
// breakpoint) gets every mask closed. Sync again once it has a size, or
// the selected label stays invisible until the value changes.
const resync = () => {
frames = 0;
stillFrames = 0;
previous = undefined;
cancelFrame(syncClips);
frame.postRender(syncClips, true);
};
const observer = new ResizeObserver(resync);
observer.observe(list);
return () => {
observer.disconnect();
cancelFrame(syncClips);
};
}, [value, children, variant, reduce, placed]);
// Arrow keys move along the list and select as focus arrives, wrapping at
// the ends; Home and End jump to the first and last enabled tab.
const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const list = listRef.current;
if (!list || !(event.target instanceof HTMLElement)) return;
const tabs = Array.from(list.querySelectorAll<HTMLButtonElement>('[role="tab"]')).filter(
(tab) => !tab.disabled && tab.closest('[role="tablist"]') === list,
);
const at = tabs.indexOf(event.target as HTMLButtonElement);
if (at < 0) return;
const rtl = getComputedStyle(list).direction === "rtl";
const forward = rtl ? "ArrowLeft" : "ArrowRight";
const back = rtl ? "ArrowRight" : "ArrowLeft";
const next =
event.key === forward
? tabs[(at + 1) % tabs.length]
: event.key === back
? tabs[(at - 1 + tabs.length) % tabs.length]
: event.key === "Home"
? tabs[0]
: event.key === "End"
? tabs[tabs.length - 1]
: null;
if (!next) return;
event.preventDefault();
next.focus();
next.click();
};
const scroll = (direction: number) => {
const viewport = viewportRef.current;
if (viewport) viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: reduce ? "instant" : "smooth" });
};
const controlClass = "absolute inset-y-0 z-20 inline-flex w-9 items-center justify-center text-foreground transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-0";
const surfaceClass = variant === "pill" ? "rounded-full bg-card" : variant === "segment" ? "rounded-lg bg-card" : "";
return (
<div ref={rootRef} className={cn("relative isolate flex w-full max-w-full min-w-0 items-center", edges.overflow && surfaceClass, wrapperClassName)}>
{edges.overflow && (
<button type="button" aria-label="Scroll tabs left" aria-controls={viewportId} disabled={!edges.left} onClick={() => scroll(-1)} className={cn(controlClass, "left-0 rounded-l-full")}>
<ChevronLeft size={20} aria-hidden="true" />
</button>
)}
<motion.div
ref={viewportRef}
id={viewportId}
layoutScroll
className={cn("w-full min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", edges.overflow && "[border-radius:inherit]")}
style={edges.overflow ? {
maskImage: `linear-gradient(to right, ${edges.left ? "transparent, black 40px" : "black, black 0px"}, ${edges.right ? "black calc(100% - 40px), transparent" : "black 100%"})`,
} : undefined}
onFocusCapture={(event) => {
if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab") reveal(event.target);
}}
>
<div {...props} ref={listRef} role="tablist" onKeyDown={onListKeyDown} className={cn(listClasses[variant], "w-max", tracked && "relative", className)}>
{children}
</div>
</motion.div>
{edges.overflow && edges.left && (
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 z-10 w-10 rounded-l-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_right,black,transparent)]" />
)}
{edges.overflow && edges.right && (
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 z-10 w-10 rounded-r-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_left,black,transparent)]" />
)}
{edges.overflow && (
<button type="button" aria-label="Scroll tabs right" aria-controls={viewportId} disabled={!edges.right} onClick={() => scroll(1)} className={cn(controlClass, "right-0 rounded-r-full")}>
<ChevronRight size={20} aria-hidden="true" />
</button>
)}
</div>
);
}
/** Space between a selected-only icon and its label, in pixels. */
const ICON_GAP = 6;
/**
* A trigger's icon before its label. Shown on every tab, or with
* `icons="selected"` springing open on the selected tab only.
*/
function TriggerIcon({ icon, active }: { icon: ReactNode; active: boolean }) {
const { icons } = useTabs();
const reduce = useReducedMotion();
if (icons === "always") {
return (
<span aria-hidden="true" className="mr-1.5 inline-flex shrink-0 items-center">
{icon}
</span>
);
}
return (
<motion.span
aria-hidden="true"
data-tabs-icon=""
initial={false}
animate={{
width: active ? "auto" : 0,
marginRight: active ? ICON_GAP : 0,
opacity: active ? 1 : 0,
scale: active ? 1 : 0.6,
}}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="inline-flex shrink-0 items-center justify-center overflow-hidden"
>
{icon}
</motion.span>
);
}
export function TabsTrigger({
value,
icon,
children,
className,
indicatorClassName,
onClick,
...props
}: {
value: string;
/** An icon before the label, such as a Lucide icon element. */
icon?: ReactNode;
indicatorClassName?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value">) {
const {
value: current,
setValue,
layoutId,
variant,
icons,
track,
placed,
tabStop,
registerTab,
unregisterTab,
panels,
tabId,
panelId,
} = useTabs();
const active = current === value;
const disabled = Boolean(props.disabled);
useLayoutEffect(() => {
registerTab(value, disabled);
}, [registerTab, value, disabled]);
useLayoutEffect(() => () => unregisterTab(value), [unregisterTab, value]);
const a11y = {
id: tabId(value),
"aria-controls": panels.has(value) ? panelId(value) : undefined,
tabIndex: !disabled && (tabStop === null || tabStop === value) ? 0 : -1,
};
const content = icon ? (
<>
<TriggerIcon icon={icon} active={active} />
{children}
</>
) : (
children
);
// React owns the initial mask only; TabsList synchronizes subsequent masks.
const [initialClip] = useState(() => active ? "inset(0)" : "inset(0 100% 0 0)");
if (variant === "underline") {
return (
<button
// Consumer ids and wiring win, e.g. several tabs sharing one panel.
{...a11y}
{...props}
type="button"
role="tab"
aria-selected={active}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) 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 whitespace-nowrap shrink-0",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{content}
{active ? (
<motion.span
layoutId={layoutId}
layout
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
// Tracked pills are placed in the list, so this wrapper must not be their
// containing block. Server HTML can't measure, so until the first
// measurement the pill fills the selected tab with CSS, like the others.
const tracked = icons === "selected" && placed;
return (
<div className={cn("shrink-0", !tracked && "relative")}>
{/* Distinct keys: reused across the swap, the CSS pill's leftover
`transform: none` would hide the tracked pill's offset until the
next value change. */}
{active && tracked ? (
<motion.span
key="tracked"
data-tabs-indicator=""
style={{ ...track, borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn("absolute top-0 left-0 bg-primary", radius, indicatorClassName)}
/>
) : active ? (
<motion.span
key="layout"
data-tabs-indicator=""
layoutId={layoutId}
layout
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
// Consumer ids and wiring win, e.g. several tabs sharing one panel.
{...a11y}
{...props}
type="button"
role="tab"
aria-selected={active}
data-tabs-value={value}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) 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",
"text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{content}
<span
data-tabs-label=""
aria-hidden="true"
inert
className="pointer-events-none absolute inset-0 inline-flex items-center justify-center text-primary-foreground [gap:inherit] [padding:inherit]"
style={{ clipPath: initialClip }}
>
{content}
</span>
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current, registerPanel, unregisterPanel, tabId, panelId } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
useLayoutEffect(() => {
registerPanel(value);
return () => unregisterPanel(value);
}, [registerPanel, unregisterPanel, value]);
const a11y = {
id: panelId(value),
role: "tabpanel",
"aria-labelledby": tabId(value),
} as const;
// 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 {...a11y} hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
{...a11y}
// Reachable with Tab so a panel without controls can still be read.
tabIndex={0}
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 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-md", className)}
>
{children}
</motion.div>
);
}
"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 { 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-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
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(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
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(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
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 { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"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}
// Own states: a label that swaps away and straight back
// re-enters mid-exit, and only owned targets are restored.
initial="initial"
animate="animate"
exit="exit"
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
LineChart
seriesreadonly XYSeries[]—categoriesreadonly string[]Unique x-axis labels in display order. Values align with them by index.
—variant?"area" | "line"`area` fills each line down to zero with a fading gradient.
—curve?"step" | "monotone" | "linear"—domain?readonly [number, number]Fixed value-axis bounds. Defaults to rounded bounds around the visible series.
—activeId?string | nullInspected category, by label.
—defaultActiveId?string | null—onActiveChange?((id: string | null) => void)—hiddenSeries?readonly string[]Series ids hidden through the legend.
—defaultHiddenSeries?readonly string[]—onHiddenSeriesChange?((ids: readonly string[]) => void)—formatValue?((value: number) => string)—formatTick?((value: number) => string)Axis tick labels. Defaults to compact notation (1.2K).
—label?string—status?"ready" | "loading"`loading` hides the lines behind a pulsing placeholder until your data arrives; switching to `ready` hands off to the draw-in. Keep one chart mounted across both states.
—loadingLabel?stringShown over the placeholder while loading. An empty string hides it.
—className?string—LineChartLegend
className?string—LineChartPlot
height?numberPlot height in px, excluding the category labels.
288strokeWidth?numberStroke width of each line, in px.
2showGrid?booleantruefadeEdges?booleanFade lines and fills out toward the left and right edges of the plot.
trueclassName?string—LineChartTooltip
className?string—side?"auto" | "left" | "right"`auto` sits right of the point, flips left near the right edge, and on plots too narrow for either moves above or below the point instead.
autoRelated components
Line Chart is an animated React line and area chart built with Framer Motion and Tailwind CSS. You pass series with one value per category; lines draw in from left to right when the chart scrolls into view, and every sample springs to its new place when the data changes, so lines, gradient fills and the value axis morph together instead of redrawing. Lines fade out toward both edges of the plot. Hovering, dragging or using the arrow keys moves a crosshair that snaps to the nearest category: the line beyond it recedes, a pill on the category axis rolls to the new label, and a tooltip springs along beside the highest value, rolling each value into place. Legend entries toggle series, and the axis rescales to what is left. Set status to loading while you fetch, and a pulsing placeholder line holds the space until the real lines draw in.
When to use it
- An analytics dashboard compares this period with the last one, or actuals with a target.
- A billing or finance page shows revenue, costs or balances over months.
- A monitoring view plots a few metrics over time and updates as new samples arrive.
Accessibility
The chart is a labelled region with one tab stop: an invisible native slider over the plot. Arrow keys, Home and End move between categories, the slider announces the category with every visible series value, and its tooltip is linked through aria-describedby. Escape closes the tooltip. Legend entries are buttons with a pressed state for each visible series. A visually hidden table lists every value, and gaps read as No data. While loading, the chart is marked busy, the loading label is announced as a status, and the slider and data table are left out until real data arrives. With reduced motion enabled, lines appear in place, data, rescale and crosshair changes happen without springs, and the loading placeholder only pulses its opacity.
FAQ
- How do I make a line chart in React?
- Install the component and pass categories, such as month labels, and series, each with an id, name, color and an array of values aligned with the categories. Leave children empty for the plot, tooltip and legend, or compose LineChartPlot, LineChartTooltip and LineChartLegend yourself.
- How do I get an area chart?
- Set variant to area. Each line fills down to zero with a fading gradient in its own color, and the value axis always includes zero. Use curve to choose monotone, linear or step lines.
- What happens with missing values?
- Pass null, or any non-finite number, and the line breaks there instead of joining across the gap. The tooltip and the hidden data table show No data for that point.
- How do I show a loading state?
- Pass status="loading" while your data is fetching and status="ready" when it resolves, on the same mounted chart. A placeholder line pulses under a shimmering label, set with loadingLabel, then fades away as the real lines draw in. Switching back to loading hides the lines again.
Updated