Bar Chart
Grouped, stacked and horizontal bars that grow in, rescale and reorder with springs.
Preview
Revenue by region
Demo data, in thousands of dollars
| Category | Subscriptions | Services | Marketplace |
|---|---|---|---|
| North America | $66K | $75K | $31K |
| Europe | $102K | $61K | $36K |
| Asia Pacific | $72K | $50K | $36K |
| Latin America | $29K | $23K | $25K |
| Middle East | $51K | $16K | $13K |
| Africa | $23K | $18K | $6K |
"use client";
import {
BarChart,
BarChartLegend,
BarChartPlot,
type BarChartSeries,
BarChartTooltip,
} from "@/components/charts/bar-chart";
/** Pass one nonnegative value per category for each series. Keep category
* labels and series ids stable so bars grow, shrink and reorder in place. */
export function BarChartExample({
series,
categories,
}: {
series: readonly BarChartSeries[];
categories: readonly string[];
}) {
return (
<BarChart
series={series}
categories={categories}
layout="stacked"
sort="descending"
label="Revenue by region"
formatValue={(value) => `$${value.toLocaleString("en-US")}`}
>
<BarChartPlot />
<BarChartTooltip />
<BarChartLegend />
</BarChart>
);
}
"use client";
// beui.dev/charts/bar-chart
import { cn } from "@/lib/utils";
import { BarChartContext, type BarChartProps, useBarChartModel } from "./bar-chart/context";
import { BarChartLegend } from "./bar-chart/legend";
import { BarChartPlot } from "./bar-chart/plot";
import { BarChartTooltip } from "./bar-chart/tooltip";
import { XYDataTable } from "./xy/data-table";
/** Compose Plot, Tooltip and Legend, or omit children for the complete chart. */
export function BarChart({
series,
categories,
layout,
orientation,
sort,
max,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
children,
className,
...props
}: BarChartProps) {
const model = useBarChartModel({
series,
categories,
layout,
orientation,
sort,
max,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
});
return (
<BarChartContext.Provider value={model}>
<section
aria-label={model.label}
{...props}
data-slot="bar-chart"
className={cn("w-full min-w-0 space-y-3", className)}
>
{children === undefined ? (
<>
<BarChartPlot />
<BarChartTooltip />
{model.rows.length > 1 ? <BarChartLegend /> : null}
</>
) : (
children
)}
<XYDataTable
label={model.label}
rows={model.rows}
categories={model.categories}
formatValue={model.formatValue}
/>
</section>
</BarChartContext.Provider>
);
}
export type { BarChartProps } from "./bar-chart/context";
export { useBarChart } from "./bar-chart/context";
export type { BarChartLegendProps } from "./bar-chart/legend";
export { BarChartLegend } from "./bar-chart/legend";
export type { BarChartPlotProps } from "./bar-chart/plot";
export { BarChartPlot } from "./bar-chart/plot";
export type { BarChartTooltipData, BarChartTooltipProps } from "./bar-chart/tooltip";
export { BarChartTooltip } from "./bar-chart/tooltip";
export type { XYSeries as BarChartSeries } from "./xy/model";
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i @floating-ui/dom 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;
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | SVGElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/charts/bar-chart
import { cn } from "@/lib/utils";
import { BarChartContext, type BarChartProps, useBarChartModel } from "./bar-chart/context";
import { BarChartLegend } from "./bar-chart/legend";
import { BarChartPlot } from "./bar-chart/plot";
import { BarChartTooltip } from "./bar-chart/tooltip";
import { XYDataTable } from "./xy/data-table";
/** Compose Plot, Tooltip and Legend, or omit children for the complete chart. */
export function BarChart({
series,
categories,
layout,
orientation,
sort,
max,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
children,
className,
...props
}: BarChartProps) {
const model = useBarChartModel({
series,
categories,
layout,
orientation,
sort,
max,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries,
onHiddenSeriesChange,
formatValue,
formatTick,
label,
});
return (
<BarChartContext.Provider value={model}>
<section
aria-label={model.label}
{...props}
data-slot="bar-chart"
className={cn("w-full min-w-0 space-y-3", className)}
>
{children === undefined ? (
<>
<BarChartPlot />
<BarChartTooltip />
{model.rows.length > 1 ? <BarChartLegend /> : null}
</>
) : (
children
)}
<XYDataTable
label={model.label}
rows={model.rows}
categories={model.categories}
formatValue={model.formatValue}
/>
</section>
</BarChartContext.Provider>
);
}
export type { BarChartProps } from "./bar-chart/context";
export { useBarChart } from "./bar-chart/context";
export type { BarChartLegendProps } from "./bar-chart/legend";
export { BarChartLegend } from "./bar-chart/legend";
export type { BarChartPlotProps } from "./bar-chart/plot";
export { BarChartPlot } from "./bar-chart/plot";
export type { BarChartTooltipData, BarChartTooltipProps } from "./bar-chart/tooltip";
export { BarChartTooltip } from "./bar-chart/tooltip";
export type { XYSeries as BarChartSeries } from "./xy/model";
"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 { niceDomain } from "../xy/scale";
import { useControllable, useInspection } from "../xy/use-inspection";
export interface BarChartProps extends Omit<ComponentProps<"section">, "children"> {
/** Nonnegative values aligned with categories. Missing or negative values leave a gap. */
series: readonly XYSeries[];
/** Unique category labels in display order. */
categories: readonly string[];
layout?: "grouped" | "stacked";
orientation?: "vertical" | "horizontal";
/** Order categories by their visible total. Bars glide to their new place when it changes. */
sort?: "none" | "ascending" | "descending";
/** Fixed top of the value axis. Defaults to a rounded value above the tallest bar. */
max?: 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;
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 useBarChartModel({
series,
categories,
layout = "grouped",
orientation = "vertical",
sort = "none",
max,
activeId,
defaultActiveId,
onActiveChange,
hiddenSeries,
defaultHiddenSeries = NONE,
onHiddenSeriesChange,
formatValue = (value) => number.format(value),
formatTick = (value) => compact.format(value),
label = "Bar chart",
}: Omit<BarChartProps, "children">) {
const data = useMemo(() => normalizeXY(series, categories, { nonnegative: true }), [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;
const totals = data.categories.map((_, i) =>
visibleRows.reduce((sum, row) => sum + (row.values[i] ?? 0), 0),
);
// Visual order. Ties keep their given order so equal bars never swap places.
const order = data.categories.map((_, i) => i);
if (sort !== "none")
order.sort((a, b) => (sort === "ascending" ? totals[a] - totals[b] : totals[b] - totals[a]) || a - b);
const domain = niceDomain(
layout === "stacked"
? totals
: visibleRows.flatMap((row) => row.values.filter((value): value is number => value !== null)),
{ includeZero: true, fixed: max !== undefined && max > 0 ? [0, max] : undefined },
);
const inspection = useInspection({
ids: data.categories,
activeId,
defaultActiveId,
onActiveChange,
});
return {
...data,
...inspection,
visibleRows,
totals,
order,
hidden,
toggle: (id: string) =>
setHidden(hidden.includes(id) ? hidden.filter((h) => h !== id) : [...hidden, id]),
highlight,
setHovered,
domain,
layout,
orientation,
formatValue,
formatTick,
label,
reduce: useReducedMotion() ?? false,
canHover: useHoverCapable(),
};
}
export type BarChartModel = ReturnType<typeof useBarChartModel>;
export const BarChartContext = createContext<BarChartModel | null>(null);
export function useBarChart() {
const context = useContext(BarChartContext);
if (!context) throw new Error("Bar chart parts must be rendered inside BarChart.");
return context;
}
"use client";
import { XYLegend } from "../xy/legend";
import { useBarChart } from "./context";
export interface BarChartLegendProps {
className?: string;
}
/** Toggle a series to hide it; its bars collapse and the axis rescales. */
export function BarChartLegend({ className }: BarChartLegendProps) {
const { rows, hidden, toggle, setHovered, canHover } = useBarChart();
return (
<XYLegend
rows={rows}
hidden={hidden}
onToggle={toggle}
onHover={setHovered}
canHover={canHover}
label="Series"
className={className}
/>
);
}
"use client";
/* Bars grow out of the baseline with a short stagger the first time the chart
* scrolls into view. Each bar is a unit rect placed with translate and scale,
* plus a rounded cap that only translates, so value changes animate
* transforms alone and the corners never stretch. Categories glide to a new
* rank when sorting reorders them; a hidden series collapses into the
* baseline while the axis rescales. Reduced motion: no growth, glide or
* stagger, values update in place. */
import { motion, type Transition, useInView } from "motion/react";
import { useEffect, useId, useState } from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { XYGridLines, XYTickLabels } from "../xy/axis";
import { XYInspector } from "../xy/inspector";
import { labelStride } from "../xy/scale";
import { useElementWidth } from "../xy/use-width";
import { useBarChart } from "./context";
/** Gutter for value labels (vertical bars), in px. */
const VALUE_AXIS = 44;
const PAD = { top: 12, right: 12, bottom: 0 } as const;
/** Fraction of each category slot the bars occupy. */
const FILL = 0.7;
const GAP = 3;
const MAX_BAR = 32;
const MAX_STACK = 56;
const RADIUS = 4;
// Collapsing to an exact zero scale can leave a hairline in some browsers.
const NONE = 0.0001;
export interface BarChartPlotProps {
/** Plot height in px. Horizontal charts default to 36px per category. */
height?: number;
/** Width of the category label gutter for horizontal bars, in px. */
labelWidth?: number;
showGrid?: boolean;
className?: string;
}
export function BarChartPlot({
height: heightProp,
labelWidth = 96,
showGrid = true,
className,
}: BarChartPlotProps) {
const {
rows,
categories,
visibleRows,
hidden,
highlight,
order,
totals,
domain,
layout,
orientation,
formatValue,
formatTick,
label,
activeIndex,
setActive,
open,
setOpen,
touching,
setTouching,
plotRef,
tooltipId,
reduce,
canHover,
} = useBarChart();
const { ref, width } = useElementWidth<HTMLDivElement>();
const inView = useInView(ref, { once: true, amount: 0.3 });
const [intro, setIntro] = useState(true);
useEffect(() => {
if (!inView) return;
const timer = setTimeout(() => setIntro(false), 900);
return () => clearTimeout(timer);
}, [inView]);
const clipId = useId();
const n = categories.length;
const vertical = orientation === "vertical";
const height = heightProp ?? (vertical ? 288 : Math.max(160, n * 36));
if (!n || !rows.length)
return (
<p
className={cn("flex items-center justify-center text-sm text-muted-foreground", className)}
style={{ height }}
>
No data
</p>
);
// Band axis runs along categories; value axis along magnitudes.
const bandStart = vertical ? VALUE_AXIS : 0;
const bandLength = vertical ? Math.max(0, width - VALUE_AXIS - PAD.right) : height;
const valueStart = vertical ? height - PAD.bottom : labelWidth;
const valueLength = vertical
? height - PAD.top - PAD.bottom
: Math.max(0, width - labelWidth - PAD.right * 2);
const slot = bandLength / n;
const scale = (value: number) => (value / (domain.max || 1)) * valueLength;
/** Value to px along the value axis, for gridlines and tick labels. */
const valueAt = (value: number) => (vertical ? valueStart - scale(value) : valueStart + scale(value));
const k = Math.max(1, visibleRows.length);
const grouped = layout === "grouped";
const thickness = grouped
? Math.max(1, Math.min(MAX_BAR, (slot * FILL - (k - 1) * GAP) / k))
: Math.max(1, Math.min(MAX_STACK, slot * FILL));
const groupThickness = grouped ? k * thickness + (k - 1) * GAP : thickness;
const radius = Math.min(RADIUS, thickness / 2);
const revealed = inView || reduce;
const spring: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const activeRank = activeIndex >= 0 ? order.indexOf(activeIndex) : -1;
const stride = labelStride(n, bandLength, vertical ? 56 : 18);
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={clipId}>
{/* Caps of short bars would poke through the baseline. */}
{vertical ? (
<rect x={0} y={-16} width={width} height={valueStart + 16} />
) : (
<rect x={valueStart} y={0} width={width} height={height} />
)}
</clipPath>
</defs>
{showGrid ? (
<XYGridLines
ticks={domain.ticks}
position={valueAt}
axis={vertical ? "y" : "x"}
from={vertical ? bandStart : 0}
to={vertical ? bandStart + bandLength : height}
reduce={reduce}
/>
) : null}
{/* A soft band behind the inspected category, the full depth of
the plot, gliding between slots. Bars stay at full strength. */}
<motion.rect
x={0}
y={0}
width={vertical ? slot : valueLength + PAD.right}
height={vertical ? valueLength : slot}
rx={4}
fill="currentColor"
className="text-foreground"
initial={false}
animate={{
x: vertical ? bandStart + Math.max(activeRank, 0) * slot : valueStart,
y: vertical ? PAD.top : Math.max(activeRank, 0) * slot,
opacity: open && activeRank >= 0 ? 0.05 : 0,
}}
// The band tracks a finger directly; any glide under touch reads as lag.
transition={{
default: touching ? { duration: 0 } : spring,
opacity: { duration: 0.1, ease: EASE_OUT },
}}
/>
<g clipPath={`url(#${clipId})`}>
{order.map((categoryIndex, rank) => {
const category = categories[categoryIndex];
const delay = intro && !reduce ? Math.min(rank * 0.035, 0.4) : 0;
const position = bandStart + rank * slot;
let stack = 0;
let visibleSlot = 0;
const topRow = [...visibleRows].reverse().find((row) => (row.values[categoryIndex] ?? 0) > 0);
return (
<motion.g
key={category}
initial={false}
animate={{ [vertical ? "x" : "y"]: position }}
transition={spring}
>
{rows.map((row) => {
const isHidden = hidden.includes(row.id);
const value = isHidden ? 0 : (row.values[categoryIndex] ?? 0);
const length = revealed ? scale(value) : 0;
const start = grouped || !revealed ? 0 : scale(stack);
const offset =
(slot - groupThickness) / 2 +
(grouped
? (isHidden ? (k - 1) / 2 : visibleSlot) * (thickness + GAP)
: 0);
if (!isHidden) {
stack += value;
visibleSlot++;
}
const capped = grouped || row.id === topRow?.id;
const inset = capped ? radius : 0;
const bodyLength = Math.max(NONE, length - inset);
const end = start + length;
const transition: Transition = {
default: { ...spring, delay },
opacity: { duration: 0.15, ease: EASE_OUT },
};
const shown = value > 0 && !isHidden;
const opacity = highlight !== null && highlight !== row.id ? 0.25 : 1;
return (
<g key={row.id} fill={row.color}>
<motion.rect
x={0}
y={0}
width={vertical ? thickness : 1}
height={vertical ? 1 : thickness}
style={{ originX: 0, originY: 0 }}
initial={false}
animate={
vertical
? {
x: offset,
y: valueStart - end + inset,
scaleY: bodyLength,
opacity: shown ? opacity : 0,
}
: {
y: offset,
x: valueStart + start,
scaleX: bodyLength,
opacity: shown ? opacity : 0,
}
}
transition={transition}
/>
{capped ? (
<motion.rect
x={0}
y={0}
width={vertical ? thickness : radius * 2}
height={vertical ? radius * 2 : thickness}
rx={radius}
initial={false}
animate={
vertical
? { x: offset, y: valueStart - end, opacity: shown ? opacity : 0 }
: {
y: offset,
x: valueStart + end - radius * 2,
opacity: shown ? opacity : 0,
}
}
transition={transition}
/>
) : null}
</g>
);
})}
</motion.g>
);
})}
</g>
</svg>
{vertical ? (
<XYTickLabels
ticks={domain.ticks}
position={valueAt}
axis="y"
format={formatTick}
reduce={reduce}
offset={VALUE_AXIS}
/>
) : (
order.map((categoryIndex, rank) => (
<motion.span
key={categories[categoryIndex]}
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-0 left-0 truncate pr-3 text-right text-xs text-muted-foreground",
rank % stride !== 0 && "invisible",
)}
style={{ width: labelWidth, lineHeight: `${slot}px` }}
initial={false}
animate={{ y: rank * slot }}
transition={spring}
>
{categories[categoryIndex]}
</motion.span>
))
)}
</>
) : null}
<div
ref={plotRef}
className="absolute rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-ring"
style={
vertical
? { left: bandStart, width: bandLength, top: PAD.top, bottom: PAD.bottom }
: { left: valueStart, width: valueLength, top: 0, height: bandLength }
}
>
<XYInspector
count={n}
activeIndex={activeRank}
onActive={(rank) => setActive(categories[order[rank]])}
label={`${label}: inspect categories`}
valueText={(rank) => {
const i = order[rank];
return [
categories[i],
...visibleRows.map((row) => {
const value = row.values[i];
return `${row.name} ${value === null ? "no data" : formatValue(value)}`;
}),
...(layout === "stacked" && visibleRows.length > 1
? [`Total ${formatValue(totals[i])}`]
: []),
].join(". ");
}}
mode="band"
orientation={vertical ? "horizontal" : "vertical"}
open={open}
setOpen={setOpen}
onTouchChange={setTouching}
tooltipId={tooltipId}
canHover={canHover}
className="inset-0 size-full"
/>
</div>
</div>
<div aria-hidden="true" className="relative mt-2 h-4">
{width > 0
? vertical
? order.map((categoryIndex, rank) => (
<motion.span
key={categories[categoryIndex]}
className={cn(
"absolute top-0 left-0 truncate px-0.5 text-center text-[11px] text-muted-foreground",
rank % stride !== 0 && "invisible",
)}
// Shown labels borrow the slots of the hidden ones around them.
style={{ width: slot * stride }}
initial={false}
animate={{ x: bandStart + (rank + 0.5) * slot - (slot * stride) / 2 }}
transition={spring}
>
{categories[categoryIndex]}
</motion.span>
))
: (
<XYTickLabels
ticks={domain.ticks}
position={valueAt}
axis="x"
format={formatTick}
reduce={reduce}
offset={0}
/>
)
: null}
</div>
</div>
);
}
"use client";
import type { ReactNode } from "react";
import { Tooltip } from "@/components/motion/tooltip";
import { cn } from "@/lib/utils";
import { XYTooltipContent, type XYTooltipRow } from "../xy/tooltip-content";
import { useBarChart } from "./context";
export interface BarChartTooltipData {
category: string;
index: number;
rows: XYTooltipRow[];
total: number;
}
export interface BarChartTooltipProps {
children?: ReactNode | ((data: BarChartTooltipData) => ReactNode);
className?: string;
side?: "top" | "right" | "bottom" | "left";
}
/**
* Follows the pointer like the other beUI charts' readouts. Keyboard
* inspection, which has no pointer, anchors at the end of the category's
* longest bar instead.
*/
export function BarChartTooltip({ children, className, side = "top" }: BarChartTooltipProps) {
const {
categories,
visibleRows,
order,
totals,
domain,
layout,
orientation,
activeIndex,
open,
setOpen,
plotRef,
tooltipId,
formatValue,
} = useBarChart();
const category = categories[activeIndex];
const rows: XYTooltipRow[] = visibleRows.map((row) => ({
id: row.id,
name: row.name,
color: row.color,
value: row.values[activeIndex] ?? null,
}));
const total = totals[activeIndex] ?? 0;
const reach = layout === "stacked" ? total : Math.max(0, ...rows.map((row) => row.value ?? 0));
const along = Math.min(1, reach / (domain.max || 1));
const across = (Math.max(order.indexOf(activeIndex), 0) + 0.5) / Math.max(categories.length, 1);
return (
<Tooltip
id={tooltipId}
anchorRef={plotRef}
followCursor
anchorPoint={
orientation === "vertical" ? { x: across, y: 1 - along } : { x: along, y: across }
}
open={open && category !== undefined}
onOpenChange={setOpen}
side={side}
className={cn("max-w-64", className)}
content={
category === undefined ? null : typeof children === "function" ? (
children({ category, index: activeIndex, rows, total })
) : (
(children ?? (
<XYTooltipContent
title={category}
rows={rows}
formatValue={formatValue}
total={layout === "stacked" && rows.length > 1 ? total : undefined}
compact
/>
))
)
}
/>
);
}
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 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 { 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 };
}
"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 { AnimatePresence } from "motion/react";
import {
type ComponentProps,
type PointerEvent as ReactPointerEvent,
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
type RefObject,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { TooltipSurface } from "@/components/motion/tooltip-surface";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
import { focusFromKeyboard, graceArea, inBox, inPolygon, watchModality } from "./tooltip/grace";
import { TooltipArrow, TooltipPositioner } from "./tooltip/positioner";
import {
type TooltipAlign,
type TooltipAnchor,
type TooltipPoint,
type TooltipSide,
useTooltipPointer,
} from "./tooltip/use-position";
export type { TooltipAlign, TooltipSide };
/* -------------------------------------------------------------------------- */
/* Provider */
/* -------------------------------------------------------------------------- */
interface TooltipTiming {
/** Delay before a hover opens a tooltip (ms). */
delay: number;
/** Delay before a tooltip closes once the pointer has left it (ms). */
closeDelay: number;
/** After one tooltip closes, others open instantly for this long (ms). */
skipDelay: number;
}
const TimingContext = createContext<TooltipTiming>({ delay: 120, closeDelay: 0, skipDelay: 300 });
export interface TooltipProviderProps extends Partial<TooltipTiming> {
children: ReactNode;
}
/** Shared timing for every tooltip inside it. Optional: tooltips work without one. */
export function TooltipProvider({ children, ...timing }: TooltipProviderProps) {
const parent = useContext(TimingContext);
const { delay = parent.delay, closeDelay = parent.closeDelay, skipDelay = parent.skipDelay } =
timing;
const value = useMemo(() => ({ delay, closeDelay, skipDelay }), [delay, closeDelay, skipDelay]);
return <TimingContext.Provider value={value}>{children}</TimingContext.Provider>;
}
/* -------------------------------------------------------------------------- */
/* Root */
/* -------------------------------------------------------------------------- */
// Moving along a toolbar should feel instant after the first label, and only
// one label may show at a time: a keyboard-focused control and a hovered one
// would otherwise both claim the screen.
let lastClosedAt = 0;
let current: { close: () => void } | null = null;
function isEmpty(content: ReactNode) {
return content == null || content === false || content === "";
}
interface TooltipContextValue {
open: boolean;
contentId: string;
labelId: string;
asLabel: boolean;
empty: boolean;
followCursor: boolean;
anchor: TooltipAnchor | null;
/** True when a TooltipTrigger owns the anchor; false for an external `anchorRef`. */
triggered: boolean;
floatingRef: RefObject<HTMLSpanElement | null>;
pointer: ReturnType<typeof useTooltipPointer>;
setTrigger: (element: TooltipAnchor | null) => void;
setEmpty: (empty: boolean) => void;
close: () => void;
onContentEnter: () => void;
onContentLeave: (event: ReactPointerEvent) => void;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(part: string) {
const context = useContext(TooltipContext);
if (!context) throw new Error(`${part} must be used inside <Tooltip>`);
return context;
}
export interface TooltipRootProps {
children?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Delay before showing on hover (ms). Keyboard focus shows immediately. */
delay?: number;
/** Delay before hiding once the pointer has left (ms). */
closeDelay?: number;
/** Never show, e.g. while the label it carries is already visible. */
disabled?: boolean;
/**
* Use the tooltip as the trigger's accessible name rather than its
* description. For icon-only controls whose only label is the tooltip.
*/
asLabel?: boolean;
/** Follow real pointer coordinates; keyboard focus still uses the anchor. */
followCursor?: boolean;
/** Existing anchor for controlled integrations such as chart cells. */
anchorRef?: RefObject<TooltipAnchor | null>;
/** Id of the tooltip surface, for anchors that reference it themselves. */
id?: string;
}
function TooltipRoot({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
delay: delayProp,
closeDelay: closeDelayProp,
disabled = false,
asLabel = false,
followCursor = false,
anchorRef,
id,
initialEmpty = false,
}: TooltipRootProps & { initialEmpty?: boolean }) {
const timing = useContext(TimingContext);
const delay = delayProp ?? timing.delay;
const closeDelay = closeDelayProp ?? timing.closeDelay;
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const open = controlledOpen ?? uncontrolledOpen;
const [empty, setEmpty] = useState(initialEmpty);
const [trigger, setTrigger] = useState<TooltipAnchor | null>(null);
const [external, setExternal] = useState<TooltipAnchor | null>(null);
const anchor = anchorRef ? external : trigger;
const generatedId = useId();
const contentId = id ?? `${generatedId}-content`;
const labelId = `${generatedId}-label`;
const floatingRef = useRef<HTMLSpanElement | null>(null);
const anchorObject = useRef<TooltipAnchor | null>(null);
const pointer = useTooltipPointer(anchor, followCursor);
// An external ref can attach after this component renders; read it once it has.
useLayoutEffect(() => {
if (!anchorRef) return;
const element = anchorRef.current ?? null;
if (element !== external) setExternal(element);
});
// Everything the event listeners read, current without re-binding them.
const latest = useRef({ open, delay, closeDelay, disabled, empty, onOpenChange, controlled: false });
useLayoutEffect(() => {
anchorObject.current = anchor;
latest.current = {
open,
delay,
closeDelay,
disabled,
empty,
onOpenChange,
controlled: controlledOpen !== undefined,
};
});
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const grace = useRef<(() => void) | null>(null);
const self = useRef<{ close: () => void }>({ close: () => {} });
const clearTimers = useCallback(() => {
if (openTimer.current) clearTimeout(openTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current);
openTimer.current = null;
closeTimer.current = null;
}, []);
const stopGrace = useCallback(() => {
grace.current?.();
grace.current = null;
}, []);
// Only a real change reaches the consumer: a pointer leaving a closed
// tooltip is not news.
const commit = useCallback((next: boolean) => {
if (latest.current.open === next) return;
if (!latest.current.controlled) setUncontrolledOpen(next);
latest.current.onOpenChange?.(next);
}, []);
const openNow = useCallback(() => {
clearTimers();
stopGrace();
if (latest.current.disabled || latest.current.empty) return;
if (current && current !== self.current) current.close();
current = self.current;
commit(true);
}, [clearTimers, stopGrace, commit]);
const close = useCallback(() => {
clearTimers();
stopGrace();
if (latest.current.open) lastClosedAt = Date.now();
if (current === self.current) current = null;
commit(false);
}, [clearTimers, stopGrace, commit]);
useLayoutEffect(() => {
self.current.close = close;
}, [close]);
const requestOpen = useCallback(() => {
clearTimers();
if (latest.current.open || latest.current.disabled || latest.current.empty) return;
const warm = Date.now() - lastClosedAt < timing.skipDelay;
if (warm || latest.current.delay <= 0) openNow();
else openTimer.current = setTimeout(openNow, latest.current.delay);
}, [clearTimers, openNow, timing.skipDelay]);
const requestClose = useCallback(() => {
clearTimers();
if (latest.current.closeDelay > 0) {
closeTimer.current = setTimeout(close, latest.current.closeDelay);
} else close();
}, [clearTimers, close]);
// Hold the label open while the pointer crosses from one box toward the other.
const startGrace = useCallback(
(exit: TooltipPoint, from: Element, to: Element | null) => {
stopGrace();
if (!to) return requestClose();
const target = to.getBoundingClientRect();
const origin = from.getBoundingClientRect();
const corridor = graceArea(exit, target);
const onMove = (event: PointerEvent) => {
const point = { x: event.clientX, y: event.clientY };
if (inBox(point, target) || inBox(point, origin)) return;
if (!inPolygon(point, corridor)) requestClose();
};
document.addEventListener("pointermove", onMove);
grace.current = () => document.removeEventListener("pointermove", onMove);
},
[stopGrace, requestClose],
);
const hover = useHoverGesture();
const tap = useTapGesture<boolean>();
// Listeners go on the trigger itself rather than on cloned props, so a
// component that owns its own handlers keeps them, and no wrapper element
// sits between the control and its layout.
useEffect(() => {
if (anchorRef || !trigger) return;
watchModality();
// A mouse press dismisses the label and keeps it away until the pointer
// leaves; hovering a control you just clicked should not bring it back.
let suppressed = false;
// Focus that came from the keyboard holds the label open on its own.
let keyboardFocus = false;
const onEnter = (event: PointerEvent) => {
if (!hover.enter(event)) return;
stopGrace();
if (closeTimer.current) clearTimeout(closeTimer.current);
if (!suppressed) requestOpen();
};
const onLeave = (event: PointerEvent) => {
if (!hover.leave(event)) return;
suppressed = false;
if (keyboardFocus) return;
if (openTimer.current) {
clearTimers();
return;
}
if (latest.current.open && !followCursor) {
startGrace({ x: event.clientX, y: event.clientY }, trigger, floatingRef.current);
} else requestClose();
};
const onDown = (event: PointerEvent) => {
tap.start(event, latest.current.open);
if (event.pointerType === "mouse") {
suppressed = true;
close();
}
};
// A finger never hovers, and Safari does not focus a button on tap either,
// so the label is only reachable if the tap itself toggles it. The click
// carries no pointerType; the pointerdown before it says whether it was a
// tap, and keyboard activation has none at all.
const onClick = () => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
if (gesture.state) close();
else openNow();
};
const onKey = () => tap.drop();
const onFocusIn = () => {
if (!focusFromKeyboard()) return;
keyboardFocus = true;
openNow();
};
const onFocusOut = (event: FocusEvent) => {
if (trigger.contains(event.relatedTarget as Node | null)) return;
keyboardFocus = false;
close();
};
// Both element kinds dispatch the same events; one type keeps the listeners typed.
const el = trigger as HTMLElement;
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointercancel", tap.drop);
el.addEventListener("click", onClick);
el.addEventListener("keydown", onKey);
el.addEventListener("focusin", onFocusIn);
el.addEventListener("focusout", onFocusOut);
return () => {
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointercancel", tap.drop);
el.removeEventListener("click", onClick);
el.removeEventListener("keydown", onKey);
el.removeEventListener("focusin", onFocusIn);
el.removeEventListener("focusout", onFocusOut);
};
}, [
anchorRef,
trigger,
followCursor,
hover,
tap,
requestOpen,
requestClose,
openNow,
close,
clearTimers,
stopGrace,
startGrace,
]);
// While open, a trigger that turns disabled loses focus without a blur event,
// and a window that loses focus never reports the pointer leaving.
useEffect(() => {
if (!open || anchorRef || !trigger) return;
const observer = new MutationObserver(() => {
if (trigger.matches(":disabled, [aria-disabled='true']")) close();
});
observer.observe(trigger, { attributes: true, attributeFilter: ["disabled", "aria-disabled"] });
window.addEventListener("blur", close);
return () => {
observer.disconnect();
window.removeEventListener("blur", close);
};
}, [open, anchorRef, trigger, close]);
// A chart drives its own open state and swaps content as it goes; only a
// trigger-owned label closes itself for having nothing to say.
useEffect(() => {
if (disabled || (empty && !anchorRef)) close();
}, [disabled, empty, anchorRef, close]);
useEffect(
() => () => {
clearTimers();
stopGrace();
if (current === self.current) current = null;
},
[clearTimers, stopGrace],
);
const insideTooltip = useCallback(
(target: Element) => Boolean(floatingRef.current?.contains(target)),
[],
);
// ...and closed by the next tap that lands somewhere else, or Escape. The
// label covers nothing interactive, so that tap passes through to what it hit.
useDismiss(open, close, anchorObject, { ignore: insideTooltip });
const onContentEnter = useCallback(() => {
stopGrace();
clearTimers();
}, [stopGrace, clearTimers]);
const onContentLeave = useCallback(
(event: ReactPointerEvent) => {
const floating = floatingRef.current;
if (!floating) return;
if (anchorRef) {
// Bridge the small gap back onto a chart; it reopens on its own terms.
clearTimers();
closeTimer.current = setTimeout(close, 100);
return;
}
startGrace({ x: event.clientX, y: event.clientY }, floating, anchorObject.current);
},
[anchorRef, clearTimers, close, startGrace],
);
const value = useMemo<TooltipContextValue>(
() => ({
open,
contentId,
labelId,
asLabel,
empty,
followCursor,
anchor,
triggered: !anchorRef,
floatingRef,
pointer,
setTrigger,
setEmpty,
close,
onContentEnter,
onContentLeave,
}),
[
open,
contentId,
labelId,
asLabel,
empty,
followCursor,
anchor,
anchorRef,
pointer,
close,
onContentEnter,
onContentLeave,
],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
/* -------------------------------------------------------------------------- */
/* Trigger */
/* -------------------------------------------------------------------------- */
function mergeIds(...ids: unknown[]) {
return ids.filter((value) => typeof value === "string" && value).join(" ") || undefined;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) (ref as { current: T | null }).current = value;
}
export interface TooltipTriggerProps extends ComponentProps<"button"> {
/** Use the single child element as the trigger instead of rendering a button. */
asChild?: boolean;
}
export function TooltipTrigger({ asChild = false, children, ref, ...props }: TooltipTriggerProps) {
const { open, contentId, labelId, asLabel, empty, setTrigger } =
useTooltipContext("TooltipTrigger");
const child = asChild && isValidElement(children) ? children : null;
const childProps = (child?.props ?? props) as Record<string, unknown>;
const childRef = child ? (childProps.ref as Ref<TooltipAnchor> | undefined) : undefined;
const attached = useRef<TooltipAnchor | null>(null);
const setRef = useCallback(
(element: TooltipAnchor | null) => {
attached.current = element;
setTrigger(element);
assignRef(childRef ?? (ref as Ref<TooltipAnchor> | undefined), element);
},
[setTrigger, childRef, ref],
);
useEffect(() => {
if (process.env.NODE_ENV !== "production" && child && !attached.current) {
console.warn(
"TooltipTrigger: the child did not attach a ref, so the tooltip has nothing to listen to. Render a DOM element or a component that passes `ref` through.",
);
}
}, [child]);
const aria = asLabel
? {
"aria-labelledby": empty
? (childProps["aria-labelledby"] as string | undefined)
: labelId,
}
: {
"aria-describedby": mergeIds(
childProps["aria-describedby"],
open && !empty ? contentId : undefined,
),
};
if (asChild) {
if (!child) return children;
return cloneElement(child as ReactElement<Record<string, unknown>>, { ref: setRef, ...aria });
}
return (
<button
type="button"
data-state={open ? "open" : "closed"}
{...props}
ref={setRef as Ref<HTMLButtonElement>}
{...aria}
>
{children}
</button>
);
}
/* -------------------------------------------------------------------------- */
/* Content */
/* -------------------------------------------------------------------------- */
export interface TooltipContentProps {
children?: ReactNode;
side?: TooltipSide;
align?: TooltipAlign;
/** Gap between anchor and tooltip (px). */
sideOffset?: number;
/** Draw an arrow pointing at the anchor. */
arrow?: boolean;
/** Point within the anchor, as fractions of its rendered width and height. */
anchorPoint?: TooltipPoint;
className?: string;
}
export function TooltipContent({
children,
side = "top",
align = "center",
sideOffset,
arrow = false,
anchorPoint,
className,
}: TooltipContentProps) {
const {
open,
contentId,
labelId,
asLabel,
followCursor,
anchor,
floatingRef,
pointer,
setEmpty,
close,
onContentEnter,
onContentLeave,
} = useTooltipContext("TooltipContent");
const empty = isEmpty(children);
const arrowRef = useRef<SVGSVGElement | null>(null);
useLayoutEffect(() => {
setEmpty(empty);
}, [empty, setEmpty]);
if (typeof document === "undefined") return null;
return createPortal(
<>
{/* The name has to exist while the label is closed, so it lives apart from the surface. */}
{asLabel && !empty ? (
<span id={labelId} hidden>
{children}
</span>
) : null}
<AnimatePresence>
{open && !empty ? (
<TooltipPositioner
key="tooltip"
anchor={anchor}
floatingRef={floatingRef}
arrowRef={arrow ? arrowRef : undefined}
anchorPoint={anchorPoint}
followCursor={followCursor}
side={side}
align={align}
sideOffset={sideOffset}
onDismiss={close}
pointer={pointer}
>
{(positioned, isPresent, placedSide) => (
<TooltipSurface
id={asLabel ? undefined : contentId}
aria-hidden={asLabel || undefined}
ready={positioned}
onPointerEnter={onContentEnter}
onPointerLeave={onContentLeave}
style={{ pointerEvents: isPresent && !followCursor ? "auto" : "none" }}
className={cn(
"max-w-[min(20rem,calc(100vw-16px))] whitespace-normal break-words",
arrow && "relative",
className,
)}
>
{children}
{arrow ? <TooltipArrow ref={arrowRef} side={placedSide} /> : null}
</TooltipSurface>
)}
</TooltipPositioner>
) : null}
</AnimatePresence>
</>,
document.body,
);
}
/* -------------------------------------------------------------------------- */
/* Tooltip (root, or one-line shorthand when given `content`) */
/* -------------------------------------------------------------------------- */
export interface TooltipProps
extends TooltipRootProps,
Omit<TooltipContentProps, "children" | "className"> {
/**
* Shorthand: the tooltip's content. With it, `children` is the trigger
* element; without it, compose `TooltipTrigger` and `TooltipContent`.
*/
content?: ReactNode;
/** Shorthand only: classes for the tooltip surface. */
className?: string;
}
export function Tooltip(props: TooltipProps) {
if (!("content" in props)) return <TooltipRoot {...props} />;
const {
content,
children,
side,
align,
sideOffset,
arrow,
anchorPoint,
className,
...root
} = props;
if (!root.anchorRef && !isValidElement(children)) return children ?? null;
return (
<TooltipRoot {...root} initialEmpty={isEmpty(content)}>
{isValidElement(children) ? <TooltipTrigger asChild>{children}</TooltipTrigger> : null}
<TooltipContent
side={side}
align={align}
sideOffset={sideOffset}
arrow={arrow}
anchorPoint={anchorPoint}
className={className}
>
{content}
</TooltipContent>
</TooltipRoot>
);
}
"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>
);
}
type Point = { x: number; y: number };
type Box = { left: number; top: number; right: number; bottom: number };
/**
* The corridor a pointer may cross between the trigger and the open tooltip
* without closing it: the convex hull of where it left and the box it is
* heading for. A fixed timeout either cuts a slow diagonal move short or keeps
* the label alive after the pointer has plainly gone elsewhere; this follows
* the path itself.
*/
export function graceArea(exit: Point, target: Box): Point[] {
// A few pixels either side of the exit point give the corridor some width at
// its narrow end, so a hand that drifts sideways while it moves stays inside.
const pad = 5;
return hull([
{ x: exit.x - pad, y: exit.y - pad },
{ x: exit.x + pad, y: exit.y - pad },
{ x: exit.x - pad, y: exit.y + pad },
{ x: exit.x + pad, y: exit.y + pad },
{ x: target.left, y: target.top },
{ x: target.right, y: target.top },
{ x: target.right, y: target.bottom },
{ x: target.left, y: target.bottom },
]);
}
export function inBox(point: Point, box: Box) {
return (
point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom
);
}
export function inPolygon(point: Point, polygon: Point[]) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i];
const b = polygon[j];
if (
a.y > point.y !== b.y > point.y &&
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
) {
inside = !inside;
}
}
return inside;
}
// Monotone chain; the corridor is always convex, so a hull is all it needs.
function hull(points: Point[]) {
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
const cross = (o: Point, a: Point, b: Point) =>
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const lower: Point[] = [];
for (const p of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
lower.pop();
}
lower.push(p);
}
const upper: Point[] = [];
for (let i = sorted.length - 1; i >= 0; i--) {
const p = sorted[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
upper.pop();
}
upper.push(p);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
/**
* How the user last reached for the page. A focus that a mouse press caused is
* not a request to read the label: the pointer is already on the control and
* hovering showed it. A focus that followed a key — Tab, or a roving arrow key
* moving focus in script — is the only way a keyboard user sees it at all.
*/
let modality: "keyboard" | "pointer" = "keyboard";
let listening = false;
export function watchModality() {
if (listening || typeof window === "undefined") return;
listening = true;
window.addEventListener("keydown", () => (modality = "keyboard"), true);
window.addEventListener("pointerdown", () => (modality = "pointer"), true);
}
export function focusFromKeyboard() {
return modality === "keyboard";
}
"use client";
import { useIsPresent } from "motion/react";
import { type CSSProperties, type ReactNode, type Ref, useCallback, useState } from "react";
import { type TooltipSide, useTooltipPosition } from "./use-position";
type PositionProps = Parameters<typeof useTooltipPosition>[0];
/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */
export function TooltipPositioner({
children,
...position
}: Omit<PositionProps, "open" | "onPosition"> & {
children: (ready: boolean, present: boolean, side: TooltipSide) => ReactNode;
}) {
const present = useIsPresent();
const [ready, setReady] = useState(false);
const [side, setSide] = useState<TooltipSide>(position.side);
const onPosition = useCallback((placed: TooltipSide) => {
setReady(true);
setSide(placed);
}, []);
useTooltipPosition({ ...position, open: present, onPosition });
return (
<span
ref={position.floatingRef}
inert={!present}
aria-hidden={!present || undefined}
className="pointer-events-none fixed left-0 top-0 z-[9999] w-max"
style={{ visibility: "hidden", maxWidth: "calc(100vw - 16px)" }}
>
{children(ready, present, side)}
</span>
);
}
// Each arrow is a 12px box whose base sits on the surface's inner border edge,
// so its fill covers the border beneath it and only the two outer edges stroke.
const ARROW: Record<TooltipSide, { path: string; style: CSSProperties }> = {
top: { path: "M0 6L6 12L12 6", style: { top: "calc(100% - 6px)" } },
bottom: { path: "M0 6L6 0L12 6", style: { bottom: "calc(100% - 6px)" } },
left: { path: "M6 0L12 6L6 12", style: { left: "calc(100% - 6px)" } },
right: { path: "M6 0L0 6L6 12", style: { right: "calc(100% - 6px)" } },
};
export function TooltipArrow({ side, ref }: { side: TooltipSide; ref: Ref<SVGSVGElement> }) {
const { path, style } = ARROW[side];
return (
<svg
ref={ref}
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 12 12"
className="pointer-events-none absolute overflow-visible"
style={style}
>
<path d={`${path}Z`} style={{ fill: "var(--tooltip-arrow-fill, transparent)" }} />
<path
d={path}
fill="none"
strokeWidth="1"
style={{ stroke: "var(--tooltip-arrow-stroke, transparent)" }}
/>
</svg>
);
}
"use client";
import {
arrow as arrowMiddleware,
autoUpdate,
computePosition,
flip,
hide,
offset,
type Placement,
shift,
type VirtualElement,
} from "@floating-ui/dom";
import { type RefObject, useCallback, useLayoutEffect, useRef } from "react";
export type TooltipSide = "top" | "right" | "bottom" | "left";
export type TooltipAlign = "start" | "center" | "end";
export type TooltipPoint = { x: number; y: number };
export type TooltipAnchor = HTMLElement | SVGElement;
const ORIGIN: Record<TooltipSide, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */
export function useTooltipPosition({
open,
anchor,
floatingRef,
arrowRef,
anchorPoint,
followCursor,
side,
align,
sideOffset,
onDismiss,
onPosition,
pointer,
}: {
open: boolean;
anchor: TooltipAnchor | null;
floatingRef: RefObject<HTMLSpanElement | null>;
arrowRef?: RefObject<SVGSVGElement | null>;
anchorPoint?: TooltipPoint;
followCursor: boolean;
side: TooltipSide;
align: TooltipAlign;
sideOffset?: number;
onDismiss: () => void;
onPosition: (side: TooltipSide) => void;
pointer: ReturnType<typeof useTooltipPointer>;
}) {
const { cursor, onMove } = pointer;
const preferred: Placement = align === "center" ? side : `${side}-${align}`;
const cursorSide = useRef<Placement | null>(null);
useLayoutEffect(() => {
cursorSide.current = open ? preferred : null;
}, [open, preferred]);
const frame = useRef<number | null>(null);
const version = useRef(0);
const update = useRef<() => void>(() => {});
const schedule = useCallback(() => {
// Invalidate older async calculations as soon as new geometry is requested.
version.current++;
if (frame.current !== null) return;
frame.current = requestAnimationFrame(() => {
frame.current = null;
update.current();
});
}, []);
useLayoutEffect(() => {
onMove.current = schedule;
return () => {
onMove.current = null;
};
}, [onMove, schedule]);
const pointX = anchorPoint?.x;
const pointY = anchorPoint?.y;
// Latest committed inputs are read without recreating observers for every period/content update.
useLayoutEffect(() => {
update.current = () => {
const floating = floatingRef.current;
if (!open || !anchor || !floating) return;
const revision = ++version.current;
const currentCursor = followCursor ? cursor.current : null;
const virtual = Boolean(currentCursor) || pointX !== undefined || pointY !== undefined;
const reference: Element | VirtualElement = virtual
? {
contextElement: anchor,
getBoundingClientRect: () => {
const rect = anchor.getBoundingClientRect();
const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);
const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);
return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };
},
}
: anchor;
const arrowElement = arrowRef?.current ?? null;
void computePosition(reference, floating, {
strategy: "fixed",
placement: currentCursor ? (cursorSide.current ?? preferred) : preferred,
middleware: [
offset(sideOffset ?? (currentCursor ? 12 : 8)),
flip({ padding: 8 }),
shift({ padding: 8 }),
...(arrowElement ? [arrowMiddleware({ element: arrowElement, padding: 10 })] : []),
// A label left floating over whatever scrolled into its trigger's place
// describes nothing there. Points inside a chart move with the chart.
...(virtual ? [] : [hide({ strategy: "referenceHidden" })]),
],
}).then(({ x, y, placement, middlewareData }) => {
if (version.current !== revision || !floating.isConnected) return;
// Hold the chosen side for this hover session. Crossing a flip threshold
// repeatedly must not bounce the surface above and below the pointer.
if (currentCursor) cursorSide.current = placement;
else cursorSide.current = null;
const placedSide = placement.split("-")[0] as TooltipSide;
const dpr = window.devicePixelRatio || 1;
floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;
floating.style.setProperty("--tooltip-origin", ORIGIN[placedSide]);
// A box with no size has no clipped state to report, only a missing layout.
const box = anchor.getBoundingClientRect();
const clipped =
Boolean(middlewareData.hide?.referenceHidden) && (box.width > 0 || box.height > 0);
floating.style.visibility = clipped ? "hidden" : "visible";
floating.dataset.placement = placement;
const surface = arrowElement?.parentElement;
if (arrowElement && surface) {
// Measured against the positioner, drawn inside the surface's border.
const { x: ax, y: ay } = middlewareData.arrow ?? {};
if (ax != null) arrowElement.style.left = `${ax - surface.clientLeft}px`;
if (ay != null) arrowElement.style.top = `${ay - surface.clientTop}px`;
// Paint with whatever the surface resolved to, so a restyled tooltip
// keeps a matching arrow without a second set of classes.
const style = getComputedStyle(surface);
arrowElement.style.setProperty("--tooltip-arrow-fill", style.backgroundColor);
arrowElement.style.setProperty("--tooltip-arrow-stroke", style.borderTopColor);
}
onPosition(placedSide);
});
};
if (open) schedule();
}, [
open,
anchor,
floatingRef,
arrowRef,
followCursor,
pointX,
pointY,
preferred,
sideOffset,
schedule,
onPosition,
cursor,
]);
useLayoutEffect(() => {
const floating = floatingRef.current;
if (!open || !anchor || !floating) return;
const stop = autoUpdate(anchor, floating, schedule);
const onScroll = () => {
// A stationary pointer no longer describes the same chart point after scrolling.
if (followCursor && cursor.current) onDismiss();
};
window.addEventListener("scroll", onScroll, true);
return () => {
stop();
window.removeEventListener("scroll", onScroll, true);
version.current++;
if (frame.current !== null) cancelAnimationFrame(frame.current);
frame.current = null;
};
}, [open, anchor, floatingRef, followCursor, onDismiss, schedule, cursor]);
useLayoutEffect(
() => () => {
version.current++;
if (frame.current !== null) cancelAnimationFrame(frame.current);
},
[],
);
}
/** Pointer lifetime belongs to the anchor, including the opening delay. */
export function useTooltipPointer(anchor: TooltipAnchor | null, followCursor: boolean) {
const cursor = useRef<TooltipPoint | null>(null);
const onMove = useRef<(() => void) | null>(null);
const pointerFocus = useRef(false);
useLayoutEffect(() => {
if (!anchor || !followCursor) return;
const point = (event: PointerEvent) => {
if (event.type === "pointermove" && event.pointerType === "touch" && !event.buttons) return;
cursor.current = { x: event.clientX, y: event.clientY };
if (event.type === "pointerdown") pointerFocus.current = true;
onMove.current?.();
};
const keyboard = () => {
cursor.current = null;
pointerFocus.current = false;
onMove.current?.();
};
const focus = () => {
if (!pointerFocus.current) cursor.current = null;
pointerFocus.current = false;
onMove.current?.();
};
const leave = () => {
cursor.current = null;
};
anchor.addEventListener("pointerenter", point as EventListener, { passive: true });
anchor.addEventListener("pointermove", point as EventListener, { passive: true });
anchor.addEventListener("pointerdown", point as EventListener, { passive: true });
anchor.addEventListener("pointerleave", leave);
anchor.addEventListener("pointercancel", leave);
anchor.addEventListener("keydown", keyboard);
anchor.addEventListener("focusin", focus);
return () => {
cursor.current = null;
anchor.removeEventListener("pointerenter", point as EventListener);
anchor.removeEventListener("pointermove", point as EventListener);
anchor.removeEventListener("pointerdown", point as EventListener);
anchor.removeEventListener("pointerleave", leave);
anchor.removeEventListener("pointercancel", leave);
anchor.removeEventListener("keydown", keyboard);
anchor.removeEventListener("focusin", focus);
};
}, [anchor, followCursor]);
return { cursor, onMove };
}
"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>
);
}
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
BarChart
seriesreadonly XYSeries[]Nonnegative values aligned with categories. Missing or negative values leave a gap.
—categoriesreadonly string[]Unique category labels in display order.
—layout?"grouped" | "stacked"—orientation?"horizontal" | "vertical"—sort?"none" | "ascending" | "descending"Order categories by their visible total. Bars glide to their new place when it changes.
—max?numberFixed top of the value axis. Defaults to a rounded value above the tallest bar.
—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—className?string—BarChartLegend
className?string—BarChartPlot
height?numberPlot height in px. Horizontal charts default to 36px per category.
—labelWidth?numberWidth of the category label gutter for horizontal bars, in px.
96showGrid?booleantrueclassName?string—BarChartTooltip
className?string—side?"bottom" | "left" | "right" | "top"topRelated components
Bar Chart is an animated React bar chart for comparing values across categories, built with Framer Motion and Tailwind CSS. It draws grouped or stacked bars, vertically or horizontally, and grows them out of the baseline with a short stagger when the chart scrolls into view. Bars are moved with transforms only and keep their rounded caps unstretched, so new data, a hidden series or a rescaled axis animate smoothly, and turning on sorting glides each category to its new rank.
When to use it
- A dashboard compares revenue, signups or usage across regions, plans or teams.
- A report breaks each month into parts with stacked bars and shows the total in the tooltip.
- A leaderboard of long category names reads better as sorted horizontal bars.
Accessibility
The chart is a labelled region with one tab stop: an invisible native slider over the plot that follows the visual order of the categories. Arrow keys, Home and End move between categories, with Arrow Down moving down the list for horizontal bars, and the slider announces each series value plus the total for stacked bars. Escape closes the tooltip. Legend entries are buttons with a pressed state. A visually hidden table lists every value. With reduced motion enabled, bars appear at full size and update without growth, glide or stagger.
FAQ
- How do I make a stacked bar chart in React?
- Install the component, pass categories and series with one value per category, and set layout to stacked. The tooltip adds a total row, and the value axis fits the tallest stack.
- Can bars be horizontal or sorted?
- Yes. Set orientation to horizontal for bars that grow to the right with category labels in a left gutter, and sort to ascending or descending to order categories by their visible total. When the order changes, bars glide to their new place.
- Does it support negative values?
- No. Bars measure nonnegative amounts from a zero baseline, and negative or missing values leave a gap. Use a line chart for values that cross zero.
Updated