Bump Chart
Animated rankings with curved paths, interactive rank dots, and tooltips.
Preview
The leaderboard
Five products. Six months. Every move.
| Series | Apr | May | Jun | Jul | Aug | Sep |
|---|---|---|---|---|---|---|
| Studio | 4 | 3 | 2 | 3 | 2 | 1 |
| Canvas | 1 | 1 | 3 | 2 | 1 | 2 |
| Layers | 3 | 2 | 1 | 1 | 3 | 3 |
| Orbit | 2 | 4 | 5 | 4 | 5 | 4 |
| Frame | 5 | 5 | 4 | 5 | 4 | 5 |
Inspect a dot for details · Select to pin a product
TSXcomponents/previews/charts/bump-chart.usage.tsx
"use client";
import {
BumpChart,
BumpChartLegend,
BumpChartPlot,
type BumpChartSeries,
} from "@/components/charts/bump-chart";
/** Supply new rank arrays to animate between snapshots; keep series IDs stable. */
export function BumpChartExample({
series,
periods,
}: {
series: readonly BumpChartSeries[];
periods: readonly string[];
}) {
return (
<BumpChart series={series} periods={periods} label="Product rankings">
<BumpChartPlot />
<BumpChartLegend />
</BumpChart>
);
}
TSXcomponents/charts/bump-chart.tsx
"use client";
// beui.dev/charts/bump-chart
import { cn } from "@/lib/utils";
import { BumpChartContext, useBumpChartModel, type BumpChartProps } from "./bump-chart/context";
import { BumpChartLegend } from "./bump-chart/legend";
import { BumpChartPlot } from "./bump-chart/plot";
/** Compose the plot and legend, or supply children for your own arrangement. */
export function BumpChart({ children, className, ...props }: BumpChartProps) {
const model = useBumpChartModel(props);
return (
<BumpChartContext.Provider value={model}>
<section aria-label={model.label} className={cn("w-full space-y-5", className)}>
{children === undefined ? (
<>
<BumpChartPlot />
<BumpChartLegend />
</>
) : (
children
)}
</section>
</BumpChartContext.Provider>
);
}
export { BumpChartLegend } from "./bump-chart/legend";
export { BumpChartPlot } from "./bump-chart/plot";
export { useBumpChart } from "./bump-chart/context";
export type { BumpChartProps } from "./bump-chart/context";
export type BumpChartSeries = import("./bump-chart/model").BumpSeries;
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/bump-chartbump-chartbump-chart
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/hooks/use-hover-capable.ts
"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;
}
TSXlib/ease.ts
// 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;
TSXlib/hooks/use-dismiss.ts
"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]);
}
TSXlib/hooks/use-hover-gesture.ts
"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";
},
}),
[],
);
}
TSXlib/hooks/use-tap-gesture.ts
"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;
},
}),
[],
);
}
TSXlib/touch.ts
// 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
TSXcomponents/charts/bump-chart.tsx
"use client";
// beui.dev/charts/bump-chart
import { cn } from "@/lib/utils";
import { BumpChartContext, useBumpChartModel, type BumpChartProps } from "./bump-chart/context";
import { BumpChartLegend } from "./bump-chart/legend";
import { BumpChartPlot } from "./bump-chart/plot";
/** Compose the plot and legend, or supply children for your own arrangement. */
export function BumpChart({ children, className, ...props }: BumpChartProps) {
const model = useBumpChartModel(props);
return (
<BumpChartContext.Provider value={model}>
<section aria-label={model.label} className={cn("w-full space-y-5", className)}>
{children === undefined ? (
<>
<BumpChartPlot />
<BumpChartLegend />
</>
) : (
children
)}
</section>
</BumpChartContext.Provider>
);
}
export { BumpChartLegend } from "./bump-chart/legend";
export { BumpChartPlot } from "./bump-chart/plot";
export { useBumpChart } from "./bump-chart/context";
export type { BumpChartProps } from "./bump-chart/context";
export type BumpChartSeries = import("./bump-chart/model").BumpSeries;
TSXcomponents/charts/bump-chart/context.tsx
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { useReducedMotion } from "motion/react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { buildBumpChart, type BumpSeries } from "./model";
export interface BumpChartProps {
series: readonly BumpSeries[];
/** Unique period labels, in chronological order. */
periods: readonly string[];
/** Pinned series ID. Null clears the selection. */
active?: string | null;
defaultActive?: string | null;
onActiveChange?: (id: string | null) => void;
label?: string;
children?: ReactNode;
className?: string;
}
export function useBumpChartModel({
series,
periods,
active,
defaultActive = null,
onActiveChange,
label = "Rankings over time",
}: BumpChartProps) {
const model = buildBumpChart(series, periods.length);
const [internal, setInternal] = useState(defaultActive);
const [hovered, setHovered] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const valid = (id: string | null) => id != null && model.rows.some((row) => row.id === id);
// Clear removed identities in this render so reappearing data cannot revive them.
if (internal !== null && !valid(internal)) setInternal(null);
if (hovered !== null && !valid(hovered)) setHovered(null);
if (focused !== null && !valid(focused)) setFocused(null);
const selected = active === undefined ? internal : active;
const pinned = valid(selected) ? selected : null;
const highlighted = valid(hovered) ? hovered : valid(focused) ? focused : pinned;
const select = (id: string | null) => {
if (active === undefined) setInternal(id);
onActiveChange?.(id);
};
return {
...model,
periods,
label,
pinned,
highlighted,
select,
setHovered,
setFocused,
reduce: useReducedMotion(),
canHover: useHoverCapable(),
};
}
export const BumpChartContext = createContext<ReturnType<typeof useBumpChartModel> | null>(null);
export function useBumpChart() {
const context = useContext(BumpChartContext);
if (!context) throw new Error("Bump chart parts must be rendered inside BumpChart.");
return context;
}
TSXcomponents/charts/bump-chart/legend.tsx
"use client";
import { cn } from "@/lib/utils";
import { useBumpChart } from "./context";
export function BumpChartLegend({ className }: { className?: string }) {
const { rows, pinned, highlighted, select, setHovered, setFocused, canHover } = useBumpChart();
return (
<div className={cn("grid grid-cols-2 gap-x-5 gap-y-1 sm:grid-cols-3", className)}>
{rows.map((row) => {
const first = row.ranks[0];
const last = row.ranks.at(-1);
const gain = first != null && last != null ? first - last : null;
return (
<button
key={row.id}
type="button"
aria-label={`Highlight ${row.name}`}
aria-pressed={pinned === row.id}
onClick={() => select(pinned === row.id ? null : row.id)}
onPointerEnter={() => {
if (canHover) setHovered(row.id);
}}
onPointerLeave={() => setHovered(null)}
onFocus={() => setFocused(row.id)}
onBlur={() => setFocused(null)}
onKeyDown={(event) => {
if (event.key === "Escape") {
select(null);
setFocused(null);
setHovered(null);
}
}}
className={cn(
"flex min-w-0 items-center gap-2 rounded-md px-2 py-2.5 text-xs text-foreground transition-opacity duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
highlighted && highlighted !== row.id && "opacity-35",
pinned === row.id && "bg-muted/60",
)}
>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="truncate">{row.name}</span>
<span className="ml-auto font-mono tabular-nums">
{last == null ? "—" : `#${last}`}
</span>
<span
className="min-w-5 text-right font-mono text-[10px] tabular-nums text-muted-foreground"
title={
gain == null
? "Change unavailable"
: gain === 0
? "Unchanged"
: `${gain > 0 ? "Up" : "Down"} ${Math.abs(gain)} places`
}
>
{gain == null || gain === 0 ? "—" : `${gain > 0 ? "↑" : "↓"}${Math.abs(gain)}`}
</span>
</button>
);
})}
</div>
);
}
TSXcomponents/charts/bump-chart/model.ts
export interface BumpSeries {
id: string;
name: string;
/** One rank per period, starting at 1. Null leaves a gap in the path. */
ranks: readonly (number | null)[];
color?: string;
}
export const BUMP_COLORS = ["#8b5cf6", "#0d9488", "#f59e0b", "#3b82f6", "#f43f5e", "#a3a33a"];
export const PLOT = { width: 560, left: 32, right: 100, top: 28, bottom: 36, rowHeight: 44 };
export function buildBumpChart(series: readonly BumpSeries[], periodCount: number) {
const seen = new Set<string>();
const rows = series
.filter((row) => {
if (seen.has(row.id)) return false;
seen.add(row.id);
return true;
})
.map((row, index) => ({
...row,
color: row.color ?? BUMP_COLORS[index % BUMP_COLORS.length],
ranks: Array.from({ length: periodCount }, (_, i) => {
const rank = row.ranks[i];
return rank != null && Number.isSafeInteger(rank) && rank > 0 ? rank : null;
}),
}));
const ranks = [
...new Set(rows.flatMap((row) => row.ranks.filter((rank): rank is number => rank != null))),
].sort((a, b) => a - b);
const maxRank = ranks.at(-1) ?? 1;
// Preserve numeric rank distances, including skipped ranks, without unbounded chart height.
const plotHeight = Math.max(1, Math.min(maxRank - 1, 8)) * PLOT.rowHeight;
const height = PLOT.top + plotHeight + PLOT.bottom;
const x = (index: number) =>
periodCount <= 1
? (PLOT.left + PLOT.width - PLOT.right) / 2
: PLOT.left + (index / (periodCount - 1)) * (PLOT.width - PLOT.left - PLOT.right);
const y = (rank: number) =>
maxRank === 1
? PLOT.top + plotHeight / 2
: PLOT.top + ((rank - 1) / (maxRank - 1)) * plotHeight;
return { rows, ranks, maxRank, height, x, y };
}
/** Missing periods break the path rather than inventing continuity. */
export function bumpPath(
ranks: readonly (number | null)[],
x: (i: number) => number,
y: (rank: number) => number,
) {
let path = "";
let previous: { x: number; y: number } | null = null;
ranks.forEach((rank, index) => {
if (rank == null) {
previous = null;
return;
}
const point = { x: x(index), y: y(rank) };
if (previous) {
const middle = (previous.x + point.x) / 2;
path += ` C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`;
} else path += ` M ${point.x} ${point.y}`;
previous = point;
});
return path.trim();
}
TSXcomponents/charts/bump-chart/plot.tsx
"use client";
import { cn } from "@/lib/utils";
import { useBumpChart } from "./context";
import { PLOT } from "./model";
import { BumpChartPoint } from "./point";
import { BumpChartSeriesPath } from "./series";
import { pointKey, useBumpGeometry } from "./use-geometry";
export function BumpChartPlot({ className }: { className?: string }) {
const { rows, ranks, height, x, y, periods, label, reduce } = useBumpChart();
const positions = useBumpGeometry(
rows.flatMap((row) =>
row.ranks.flatMap((rank, index) =>
rank == null ? [] : [{ key: pointKey(row.id, periods[index]), x: x(index), y: y(rank) }],
),
),
!!reduce,
);
if (!periods.length || !ranks.length)
return (
<div className={cn("py-16 text-center text-sm text-muted-foreground", className)}>
No rankings yet
</div>
);
// Keep labels and rank dots readable inside narrow preview containers.
const width = Math.max(PLOT.width, periods.length * 64);
return (
<section
aria-label={`${label} plot, scroll horizontally for more periods`}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users need to scroll this overflow region.
tabIndex={0}
className={cn(
"overflow-x-auto rounded-lg focus-visible:outline-2 focus-visible:outline-ring",
className,
)}
>
<div className="relative" style={{ minWidth: width }}>
<svg
role="img"
aria-label={label}
viewBox={`0 0 ${PLOT.width} ${height}`}
className="block w-full overflow-visible"
>
<title>{label}</title>
<desc>
Ranks run from best at the top to lowest at the bottom. Use the legend to highlight or
pin a series. Exact values follow in a table.
</desc>
{ranks.map((rank) => (
<line
key={rank}
x1={PLOT.left}
x2={PLOT.width - PLOT.right}
y1={y(rank)}
y2={y(rank)}
className="stroke-border"
strokeDasharray="2 5"
/>
))}
{periods.map((period, index) => (
<text
key={period}
x={x(index)}
y={height - 8}
textAnchor="middle"
className="fill-muted-foreground font-mono text-[10px]"
>
{period}
</text>
))}
{rows.map((row, index) => (
<BumpChartSeriesPath
key={row.id}
row={row}
index={index}
positions={periods.map((period) => positions.get(pointKey(row.id, period)) ?? null)}
/>
))}
</svg>
<div className="pointer-events-none absolute inset-0 overflow-clip">
{rows.map((row, seriesIndex) =>
row.ranks.map((rank, point) =>
rank == null ? null : (
<BumpChartPoint
key={`${row.id}-${periods[point]}`}
seriesId={row.id}
period={periods[point]}
seriesIndex={seriesIndex}
position={positions.get(pointKey(row.id, periods[point]))}
/>
),
),
)}
</div>
</div>
<table className="sr-only">
<caption>{label} — exact ranks</caption>
<thead>
<tr>
<th scope="col">Series</th>
{periods.map((period) => (
<th key={period} scope="col">
{period}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.name}</th>
{row.ranks.map((rank, index) => (
<td key={periods[index]}>{rank ?? "No data"}</td>
))}
</tr>
))}
</tbody>
</table>
</section>
);
}
TSXcomponents/charts/bump-chart/point.tsx
"use client";
import { motion, useTransform } from "motion/react";
import type { ReactNode } from "react";
import { Tooltip } from "@/components/motion/tooltip";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useBumpChart } from "./context";
import { PLOT } from "./model";
import type { BumpPosition } from "./use-geometry";
export function BumpChartPoint({
seriesId,
period,
seriesIndex,
position,
}: {
seriesId: string;
period: string;
seriesIndex: number;
position: BumpPosition | undefined;
}) {
const { rows, periods, highlighted, pinned, select, setHovered, setFocused, canHover, reduce } =
useBumpChart();
const row = rows.find((item) => item.id === seriesId);
const index = periods.indexOf(period);
const rank = row?.ranks[index];
if (!row || rank == null || !position) return null;
const previous = row.ranks[index - 1];
const change = previous == null ? null : previous - rank;
const active = highlighted === row.id;
const dimmed = highlighted !== null && !active;
const changeLabel =
change == null
? "No previous rank"
: change === 0
? "No change"
: `${change > 0 ? "Up" : "Down"} ${Math.abs(change)} ${Math.abs(change) === 1 ? "place" : "places"}`;
return (
<MovingPoint position={position}>
<div
className="pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
onPointerEnter={() => {
if (canHover) setHovered(row.id);
}}
onPointerLeave={() => setHovered(null)}
>
<Tooltip
delay={60}
className="min-w-[156px]"
content={
<>
<span className="flex items-center justify-between gap-4 text-[10px] text-muted-foreground">
<span>{row.name}</span>
<span>{period}</span>
</span>
<span className="mt-2 flex items-baseline justify-between gap-4">
<span className="font-mono text-lg font-medium" style={{ color: row.color }}>
#{rank}
</span>
<span className="text-xs">{changeLabel}</span>
</span>
{previous != null && (
<span className="mt-1 block text-[10px] text-muted-foreground">
Previously #{previous} in {periods[index - 1]}
</span>
)}
</>
}
>
<motion.button
type="button"
aria-label={`${row.name}, ${period}: rank ${rank}`}
aria-pressed={pinned === row.id}
onFocus={() => setFocused(row.id)}
onBlur={() => setFocused(null)}
onClick={() => select(pinned === row.id ? null : row.id)}
onKeyDown={(event) => {
if (event.key === "Escape") {
select(null);
setFocused(null);
setHovered(null);
}
}}
initial={reduce ? false : { transform: "scale(0.85)", opacity: 0 }}
animate={{ transform: "scale(1)", opacity: dimmed ? 0.22 : 1 }}
transition={{ transform: SPRING_PRESS, opacity: { duration: 0.18, ease: EASE_OUT } }}
whileHover={canHover && !reduce ? { transform: "scale(1.12)" } : undefined}
className="relative flex size-7 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
style={{ color: row.color }}
>
<motion.span
aria-hidden="true"
className="absolute inset-0 rounded-full bg-current"
initial={false}
animate={{ opacity: active ? 0.12 : 0 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
/>
<motion.span
className="relative flex size-5 items-center justify-center rounded-full border-[1.5px] border-current bg-background font-mono text-[9px] font-medium"
initial={reduce ? false : { transform: "scale(0.85)" }}
animate={{ transform: "scale(1)" }}
transition={{
...SPRING_PRESS,
delay: reduce ? 0 : Math.min(index, 8) * 0.025 + Math.min(seriesIndex, 4) * 0.015,
}}
>
{rank}
</motion.span>
</motion.button>
</Tooltip>
</div>
</MovingPoint>
);
}
function MovingPoint({ position, children }: { position: BumpPosition; children: ReactNode }) {
const { height } = useBumpChart();
const transform = useTransform(
[position.x, position.y],
([x, y]: number[]) => `translate(${(x / PLOT.width) * 100}%, ${(y / height) * 100}%)`,
);
return (
<motion.div className="pointer-events-none absolute inset-0" style={{ transform }}>
{children}
</motion.div>
);
}
TSXcomponents/charts/bump-chart/series.tsx
"use client";
import { useId, useRef } from "react";
import { motion, useTransform, useMotionValueEvent } from "motion/react";
import { EASE_OUT } from "@/lib/ease";
import { useBumpChart } from "./context";
import { bumpPath, PLOT } from "./model";
import type { BumpPosition } from "./use-geometry";
type Row = ReturnType<typeof useBumpChart>["rows"][number];
export function BumpChartSeriesPath({
row,
positions,
index,
}: {
row: Row;
positions: (BumpPosition | null)[];
index: number;
}) {
const { height, highlighted, setHovered, canHover, reduce } = useBumpChart();
const clip = useId();
const values = positions.flatMap((point) => (point ? [point.x, point.y] : []));
const path = useTransform(values, (coordinates: number[]) => {
let cursor = 0;
const points = positions.map((point) =>
point ? { x: coordinates[cursor++], y: coordinates[cursor++] } : null,
);
return bumpPath(
points.map((point) => point?.y ?? null),
(i) => points[i]?.x ?? 0,
(value) => value,
);
});
const last = positions.at(-1);
const labelTransform = useTransform(
values,
() => `translate(${last?.x.get() ?? 0} ${last?.y.get() ?? 0})`,
);
const labelRef = useRef<SVGGElement>(null);
useMotionValueEvent(labelTransform, "change", (transform) => {
labelRef.current?.setAttribute("transform", transform);
});
const dimmed = highlighted !== null && highlighted !== row.id;
return (
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: dimmed ? 0.22 : 1 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
onPointerEnter={() => {
if (canHover) setHovered(row.id);
}}
onPointerLeave={() => setHovered(null)}
>
<defs>
<clipPath id={clip}>
<motion.rect
x="0"
y="0"
width={PLOT.width}
height={height}
initial={reduce ? false : { transform: "scaleX(0)" }}
animate={{ transform: "scaleX(1)" }}
transition={{ duration: 0.28, delay: Math.min(index, 4) * 0.025, ease: EASE_OUT }}
/>
</clipPath>
</defs>
<g clipPath={`url(#${clip})`}>
{/* SVG geometry must reshape with the animated points; transforming a whole path cannot express new ranks. */}
<motion.path d={path} stroke={row.color} strokeWidth="2" fill="none" />
<motion.path
d={path}
stroke={row.color}
strokeWidth="5"
fill="none"
initial={false}
animate={{ opacity: highlighted === row.id ? 0.16 : 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
/>
<motion.path d={path} stroke="transparent" strokeWidth="16" fill="none" />
</g>
{last && (
<g ref={labelRef} transform={labelTransform.get()}>
<text
x="18"
dominantBaseline="central"
fill={row.color}
className="text-[10px] font-medium"
>
{row.name.length > 12 ? `${row.name.slice(0, 11)}…` : row.name}
<title>{row.name}</title>
</text>
</g>
)}
</motion.g>
);
}
TSXcomponents/charts/bump-chart/use-geometry.ts
"use client";
import { animate, motionValue, type MotionValue } from "motion/react";
import { useLayoutEffect, useState } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
export type BumpPosition = { x: MotionValue<number>; y: MotionValue<number> };
export type BumpTarget = { key: string; x: number; y: number };
export const pointKey = (series: string, period: string) => JSON.stringify([series, period]);
/** The curves, dots, and end labels all read these exact same animated coordinates. */
export function useBumpGeometry(targets: BumpTarget[], reduce: boolean) {
const shape = JSON.stringify(targets.map((target) => target.key));
const create = (previous?: Map<string, BumpPosition>) =>
new Map(
targets.map((target) => [
target.key,
previous?.get(target.key) ?? { x: motionValue(target.x), y: motionValue(target.y) },
]),
);
const [stored, setStored] = useState(() => ({ shape, positions: create() }));
let positions = stored.positions;
if (stored.shape !== shape) {
positions = create(stored.positions);
setStored({ shape, positions });
}
// Hover/focus renders do not restart a running transition. Only new geometry does.
const snapshot = JSON.stringify(targets);
useLayoutEffect(() => {
const next: BumpTarget[] = JSON.parse(snapshot);
const controls: ReturnType<typeof animate>[] = [];
for (const target of next) {
const position = positions.get(target.key);
if (!position) continue;
for (const axis of ["x", "y"] as const) {
if (reduce) position[axis].jump(target[axis]);
else controls.push(animate(position[axis], target[axis], SPRING_LAYOUT));
}
}
return () => {
for (const control of controls) control.stop();
};
}, [snapshot, reduce, positions]);
return positions;
}
TSXcomponents/motion/tooltip.tsx
"use client";
import { AnimatePresence } from "motion/react";
import {
cloneElement,
isValidElement,
type PointerEvent,
type ReactElement,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useId,
useLayoutEffect,
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";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children?: ReactElement;
/** Existing trigger for controlled integrations such as chart cells. */
anchorRef?: RefObject<HTMLElement | SVGElement | null>;
/** Point within the anchor, as fractions of its rendered width and height. */
anchorPoint?: { x: number; y: number };
open?: boolean;
onOpenChange?: (open: boolean) => void;
id?: string;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
anchorRef: externalAnchorRef,
anchorPoint,
open: controlledOpen,
onOpenChange,
id: providedId,
}: TooltipProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (controlledOpen === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[controlledOpen, onOpenChange],
);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const generatedId = useId();
const id = providedId ?? generatedId;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapperRef = useRef<HTMLSpanElement>(null);
const anchorRef = externalAnchorRef ?? wrapperRef;
const hover = useHoverGesture();
const surfaceRef = useRef<HTMLSpanElement>(null);
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width * (anchorPoint?.x ?? 0.5);
const cy = r.top + r.height * (anchorPoint?.y ?? 0.5);
const point: Record<Side, { top: number; left: number }> = {
top: { top: (anchorPoint ? cy : r.top) - GAP, left: cx },
bottom: { top: (anchorPoint ? cy : r.bottom) + GAP, left: cx },
left: { top: cy, left: (anchorPoint ? cx : r.left) - GAP },
right: { top: cy, left: (anchorPoint ? cx : r.right) + GAP },
};
const next = point[side];
const width = surfaceRef.current?.offsetWidth ?? 0;
const height = surfaceRef.current?.offsetHeight ?? 0;
const dx = side === "left" ? width : side === "right" ? 0 : width / 2;
const dy = side === "top" ? height : side === "bottom" ? 0 : height / 2;
next.left = Math.max(GAP + dx, Math.min(next.left, window.innerWidth - GAP - width + dx));
next.top = Math.max(GAP + dy, Math.min(next.top, window.innerHeight - GAP - height + dy));
setCoords(previous => previous?.top === next.top && previous.left === next.left ? previous : next);
}, [side, anchorRef, anchorPoint]);
const positioned = coords !== null;
useLayoutEffect(() => {
if (!open) return;
place();
const observer = new ResizeObserver(place);
if (anchorRef.current) observer.observe(anchorRef.current);
if (positioned && surfaceRef.current) observer.observe(surfaceRef.current);
return () => observer.disconnect();
}, [open, place, anchorRef, positioned]);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [delay, place, setOpen]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) lastHiddenAt = Date.now();
setOpen(false);
}, [open, setOpen]);
// A finger never hovers, and Safari does not focus a button on tap either, so
// the label is only reachable if the tap itself opens the tooltip. A click
// carries no pointerType, so the pointerdown that preceded it is what says
// whether this was a tap; keyboard activation arrives with no pointerdown at
// all, and focus has already shown the label there.
const tap = useTapGesture<boolean>();
const toggleOnTap = useCallback(() => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
if (gesture.state) {
hide();
return;
}
if (timer.current) clearTimeout(timer.current);
place();
setOpen(true);
}, [hide, place, tap, setOpen]);
// ...and closed again by the next tap that lands somewhere else. The label
// covers nothing interactive, so that tap passes through to what it hit.
useDismiss(open, hide, anchorRef);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
if (!externalAnchorRef && !isValidElement(children)) return children;
// The label describes the trigger, so it has to name the trigger itself.
// Everything else the tooltip needs is read off the anchor below instead of
// cloned on: a handler written onto the child is the child's handler as far
// as that child can tell, and a component that owns its activation —
// hard-wiring onClick and spreading the rest of its props over it, as
// ThemeToggle does — then runs the tooltip's instead of its own. Composing
// with `props.onClick` cannot save it either, because a component element's
// props hold nothing the component does internally.
const trigger = isValidElement(children)
? cloneElement(children as ReactElement<Record<string, unknown>>, {
"aria-describedby": id,
})
: null;
return (
<>
{!externalAnchorRef ? (
// biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.
<span
ref={wrapperRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
// Pointer events, not the mouse pair: a tap fires compatibility
// mouseenter/mouseleave that carry no pointerType, which raced the tap
// path into opening and closing the same label.
onPointerEnter={(event: PointerEvent) => {
if (hover.enter(event)) show();
}}
onPointerLeave={(event: PointerEvent) => {
if (hover.leave(event)) hide();
}}
onFocus={show}
onBlur={hide}
onPointerDown={(event: PointerEvent) => tap.start(event, open)}
// A gesture the platform took away sends no click, and a key press
// starts an activation that never had a pointer behind it. Either way
// the record has to go, or the next click reads a finger that has long
// since lifted.
onPointerCancel={tap.drop}
onKeyDown={tap.drop}
onClick={toggleOnTap}
>
{trigger}
</span>
) : null}
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<TooltipSurface
ref={surfaceRef}
id={id}
side={side}
style={{ transformOrigin: transformOrigin[side], maxWidth: "calc(100vw - 16px)", whiteSpace: "normal" }}
className={className}
>
{content}
</TooltipSurface>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
TSXcomponents/motion/tooltip-surface.tsx
"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { useMemo, type ComponentProps, type ReactNode, type Ref } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
// Small tooltip surfaces need the lighter spawn used by the original Tooltip.
const TOOLTIP_SPRING = { type: "spring", stiffness: 380, damping: 30, mass: 0.7 } as const;
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
...TOOLTIP_SPRING,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
/** The shared visual surface for trigger tooltips and chart readouts. Positioning belongs to the caller. */
export function TooltipSurface({
children,
side = "top",
className,
ref,
...props
}: Omit<ComponentProps<typeof motion.span>, "children"> & {
children?: ReactNode;
side?: Side;
ref?: Ref<HTMLSpanElement>;
}) {
const reduce = useReducedMotion();
const variants = useMemo(() => reduce ? REDUCED_VARIANTS : buildVariants(side), [reduce, side]);
return (
<motion.span
ref={ref}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
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>
);
}
TSXcomponents/motion/button/index.tsx
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";
TSXcomponents/motion/button/base.tsx
"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-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"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>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"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>
);
});
TSXcomponents/motion/button/metallic.tsx
"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>
);
});
TSXcomponents/motion/button/stateful.tsx
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
TSXcomponents/motion/magnetic.tsx
"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
BumpChart
seriesreadonly BumpSeries[]—periodsreadonly string[]Unique period labels, in chronological order.
—active?string | nullPinned series ID. Null clears the selection.
—defaultActive?string | null—onActiveChange?((id: string | null) => void)—label?string—className?string—BumpChartLegend
className?string—BumpChartPlot
className?string—Related components
Funnel Chart
Vertical and horizontal funnels with curved stages and animated number tooltips.
Liquidity Heatmap
Animated liquidity bands across price and time, with a price trace and depth tooltips.
Price Target Fan
Composable price target chart with Header, Plot, SVG, Axes, History, Targets, Now, Cursor, and Tooltip parts. Supply dated price history and targets; scrub with a pointer or keyboard and customize the active readout.
Updated