Sankey Chart
Weighted flows between stages that draw in and morph with your data.
Preview
| Flow | Value | Share of source |
|---|---|---|
| Subscriptions to Revenue | $70M | 100.0% |
| Services to Revenue | $17M | 100.0% |
| Marketplace to Revenue | $14M | 100.0% |
| Revenue to Cost of revenue | $36M | 35.6% |
| Revenue to Gross profit | $65M | 64.4% |
| Gross profit to R&D | $22M | 33.8% |
| Gross profit to Sales & marketing | $17M | 26.2% |
| Gross profit to G&A | $7M | 10.8% |
| Gross profit to Operating profit | $19M | 29.2% |
"use client";
import {
SankeyChart,
SankeyChartPlot,
SankeyChartTooltip,
type SankeyLink,
type SankeyNode,
} from "@/components/charts/sankey-chart";
/** Pass nodes and weighted links between their ids; columns follow the flows. */
export function SankeyChartExample({
nodes,
links,
}: {
nodes: readonly SankeyNode[];
links: readonly SankeyLink[];
}) {
return (
<SankeyChart
nodes={nodes}
links={links}
label="Income statement"
formatValue={(value) => `$${value.toLocaleString("en-US")}M`}
>
<SankeyChartPlot className="h-80" />
<SankeyChartTooltip />
</SankeyChart>
);
}
"use client";
// beui.dev/charts/sankey-chart
import { cn } from "@/lib/utils";
import { SankeyContext, type SankeyChartProps, useSankeyModel } from "./sankey-chart/context";
import { SankeyChartPlot } from "./sankey-chart/plot";
import { SankeyChartTooltip } from "./sankey-chart/tooltip";
/**
* Weighted flows between nodes, laid out in columns from sources to outcomes.
* Compose Plot and Tooltip, or omit children for the complete chart.
*
* ```tsx
* <SankeyChart nodes={nodes} links={links} formatValue={usd}>
* <SankeyChartPlot />
* <SankeyChartTooltip />
* </SankeyChart>
* ```
*/
export function SankeyChart({
nodes,
links,
label,
formatValue,
colors,
nodeWidth,
nodePadding,
activeId,
defaultActiveId,
onActiveChange,
className,
children,
...props
}: SankeyChartProps) {
const model = useSankeyModel({
nodes,
links,
label,
formatValue,
colors,
nodeWidth,
nodePadding,
activeId,
defaultActiveId,
onActiveChange,
});
return (
<SankeyContext.Provider value={model}>
<section
aria-label={model.label}
{...props}
data-slot="sankey-chart"
className={cn("w-full min-w-0", className)}
>
{children === undefined ? (
<>
<SankeyChartPlot />
<SankeyChartTooltip />
</>
) : (
children
)}
<div className="sr-only">
<table>
<caption>{model.label} data</caption>
<thead>
<tr>
<th scope="col">Flow</th>
<th scope="col">Value</th>
<th scope="col">Share of source</th>
</tr>
</thead>
<tbody>
{model.flows.map((flow) => (
<tr key={flow.id}>
<th scope="row">
{flow.source} to {flow.target}
</th>
<td>{model.formatValue(flow.value)}</td>
<td>{flow.share ? `${(flow.share * 100).toFixed(1)}%` : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</SankeyContext.Provider>
);
}
export {
type SankeyActive,
type SankeyChartProps,
useSankeyChart,
} from "./sankey-chart/context";
export type {
PlacedLink as SankeyPlacedLink,
PlacedNode as SankeyPlacedNode,
SankeyLink,
SankeyNode,
} from "./sankey-chart/model";
export { SankeyChartPlot, type SankeyChartPlotProps } from "./sankey-chart/plot";
export { SankeyChartTooltip, type SankeyChartTooltipProps } from "./sankey-chart/tooltip";
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;
}
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/**
* Where the keyboard or the pointer last moved to: the row's id, stamped with
* the query it was placed under.
*/
type RowCursor = { id: string; query: string };
/** The cursor's row, or -1 once the query has moved on or the row has left. */
function indexOfCursor(
rows: readonly { id: string }[],
query: string,
cursor: RowCursor | null,
) {
if (cursor === null || cursor.query !== query) return -1;
return rows.findIndex((row) => row.id === cursor.id);
}
/**
* The highlighted row of a list whose rows can change under it.
*
* Which row is highlighted is resolved during render, never in a passive
* effect: a passive effect runs after the commit, so a list that had just
* changed would carry an `aria-activedescendant` naming a row that has left it,
* and a key pressed in that window would commit the wrong row or nothing at
* all.
*
* The cursor holds the row's id, not its position. A position alone cannot tell
* a list that shrank from one that swapped its rows for a different set of the
* same length, and the second case is the one that silently hands Enter to a
* row the user never chose. A cursor whose row has left the list returns the
* highlight to the first row rather than to the nearest surviving one: the row
* the user aimed at is gone, and the first row is where a new query already
* puts the highlight.
*
* Pass the query the list is filtered by. The cursor is stamped with it and
* dropped when it changes, so a caller cannot forget to clear it — including a
* caller whose query arrives as a prop and so never runs its own handler. Rows
* that come back under a query that has moved on cannot revive it either.
* `moveTo(null)` is for deliberate resets, such as reopening the list.
*
* Both callbacks keep one identity for the life of the component, and read the
* rows and the query through a ref to do it. A caller will put them in an
* effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`
* rebuilt on every keystroke would re-run that effect on every keystroke.
*/
export function useRowCursor(rows: readonly { id: string }[], query: string) {
const [cursor, setCursor] = useState<RowCursor | null>(null);
// Written after commit, not during render: a render React discards or has not
// finished still runs the component body, and an event handler that read this
// in that window would stamp the cursor with a query the committed tree does
// not have.
const latest = useRef({ rows, query });
useLayoutEffect(() => {
latest.current = { rows, query };
});
const cursorRow = indexOfCursor(rows, query, cursor);
// Cleared rather than ignored: React re-runs this render with the cursor
// already gone, so rows that come back cannot revive a highlight the user has
// stopped aiming at.
if (cursor !== null && cursorRow < 0) setCursor(null);
const moveTo = useCallback(
(id: string | null) =>
setCursor(id === null ? null : { id, query: latest.current.query }),
[],
);
const moveActive = useCallback((direction: 1 | -1) => {
const { rows: live, query: liveQuery } = latest.current;
const last = live.length - 1;
if (last < 0) return;
// Steps from the row the cursor is really on, inside the update, so that
// two keys landing in one batch move two rows rather than one.
setCursor((current) => {
const at = Math.max(indexOfCursor(live, liveQuery, current), 0);
const next = Math.min(Math.max(at + direction, 0), last);
return { id: live[next].id, query: liveQuery };
});
}, []);
return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };
}
// 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/sankey-chart
import { cn } from "@/lib/utils";
import { SankeyContext, type SankeyChartProps, useSankeyModel } from "./sankey-chart/context";
import { SankeyChartPlot } from "./sankey-chart/plot";
import { SankeyChartTooltip } from "./sankey-chart/tooltip";
/**
* Weighted flows between nodes, laid out in columns from sources to outcomes.
* Compose Plot and Tooltip, or omit children for the complete chart.
*
* ```tsx
* <SankeyChart nodes={nodes} links={links} formatValue={usd}>
* <SankeyChartPlot />
* <SankeyChartTooltip />
* </SankeyChart>
* ```
*/
export function SankeyChart({
nodes,
links,
label,
formatValue,
colors,
nodeWidth,
nodePadding,
activeId,
defaultActiveId,
onActiveChange,
className,
children,
...props
}: SankeyChartProps) {
const model = useSankeyModel({
nodes,
links,
label,
formatValue,
colors,
nodeWidth,
nodePadding,
activeId,
defaultActiveId,
onActiveChange,
});
return (
<SankeyContext.Provider value={model}>
<section
aria-label={model.label}
{...props}
data-slot="sankey-chart"
className={cn("w-full min-w-0", className)}
>
{children === undefined ? (
<>
<SankeyChartPlot />
<SankeyChartTooltip />
</>
) : (
children
)}
<div className="sr-only">
<table>
<caption>{model.label} data</caption>
<thead>
<tr>
<th scope="col">Flow</th>
<th scope="col">Value</th>
<th scope="col">Share of source</th>
</tr>
</thead>
<tbody>
{model.flows.map((flow) => (
<tr key={flow.id}>
<th scope="row">
{flow.source} to {flow.target}
</th>
<td>{model.formatValue(flow.value)}</td>
<td>{flow.share ? `${(flow.share * 100).toFixed(1)}%` : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</SankeyContext.Provider>
);
}
export {
type SankeyActive,
type SankeyChartProps,
useSankeyChart,
} from "./sankey-chart/context";
export type {
PlacedLink as SankeyPlacedLink,
PlacedNode as SankeyPlacedNode,
SankeyLink,
SankeyNode,
} from "./sankey-chart/model";
export { SankeyChartPlot, type SankeyChartPlotProps } from "./sankey-chart/plot";
export { SankeyChartTooltip, type SankeyChartTooltipProps } from "./sankey-chart/tooltip";
"use client";
import { useReducedMotion } from "motion/react";
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import {
cleanSankey,
layoutSankey,
type PlacedLink,
type PlacedNode,
SANKEY_COLORS,
type SankeyLink,
type SankeyNode,
} from "./model";
export interface SankeyChartProps extends ComponentProps<"section"> {
nodes: readonly SankeyNode[];
/** Weighted flows between node ids. Links that would close a loop are left out. */
links: readonly SankeyLink[];
label?: string;
formatValue?: (value: number) => string;
/** Palette for nodes without their own color, in node order. */
colors?: readonly string[];
/** Node bar width in px. */
nodeWidth?: number;
/** Space between nodes in a column, in px. */
nodePadding?: number;
/** Highlighted node or link id. Controlled. Link ids default to `source:target`. */
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string | null) => void;
}
export type SankeyActive =
| { type: "node"; node: PlacedNode }
| { type: "link"; link: PlacedLink };
const defaultFormat = (value: number) =>
value.toLocaleString("en-US", { maximumFractionDigits: 2 });
export function useSankeyModel({
nodes,
links,
label = "Sankey chart",
formatValue = defaultFormat,
colors = SANKEY_COLORS,
nodeWidth = 12,
nodePadding = 16,
activeId,
defaultActiveId = null,
onActiveChange,
}: SankeyChartProps) {
const data = useMemo(() => cleanSankey(nodes, links), [nodes, links]);
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
const { invalid, cycle } = data.dropped;
if (invalid.length || cycle.length)
console.warn(
`SankeyChart left out ${invalid.length} invalid and ${cycle.length} loop-closing link(s).`,
{ invalid, cycle },
);
}, [data]);
const [size, setSize] = useState({ width: 640, height: 320 });
// A resize re-lays the chart at once; only a data change springs. The
// render that commits a new size carries `resized`, so its transitions are
// instant; the follow-up render has the same targets and starts nothing.
const sizeKey = `${size.width}x${size.height}`;
const [settledSize, setSettledSize] = useState(sizeKey);
const resized = settledSize !== sizeKey;
useLayoutEffect(() => {
if (resized) setSettledSize(sizeKey);
}, [resized, sizeKey]);
const layout = useMemo(
() =>
layoutSankey(data, {
width: size.width,
height: size.height,
nodeWidth,
nodePadding,
colors,
}),
[data, size, nodeWidth, nodePadding, colors],
);
const cursor = useRowCursor(layout.nodes, "");
const [internalId, setInternalId] = useState(defaultActiveId);
const requestedId = activeId === undefined ? internalId : activeId;
// Resolved during render by id: a node or link that left the data clears.
const node = layout.nodes.find((n) => n.id === requestedId);
const link = node ? undefined : layout.links.find((l) => l.id === requestedId);
const active: SankeyActive | null = node
? { type: "node", node }
: link
? { type: "link", link }
: null;
if (activeId === undefined && internalId !== null && !active) setInternalId(null);
const latest = useRef({ activeId, requestedId, onActiveChange });
useLayoutEffect(() => {
latest.current = { activeId, requestedId, onActiveChange };
});
const setActive = useCallback((id: string | null) => {
const current = latest.current;
if (current.activeId === undefined) setInternalId(id);
if (current.requestedId !== id) current.onActiveChange?.(id);
}, []);
const [tooltipOpen, setTooltipOpen] = useState(false);
const plotRef = useRef<HTMLDivElement>(null);
const buttons = useRef(new Map<string, HTMLButtonElement>());
const registerButton = useCallback((id: string, element: HTMLButtonElement | null) => {
if (element) buttons.current.set(id, element);
else buttons.current.delete(id);
}, []);
// Every drawable flow for the exact-data table, with shares from the
// cleaned data so the table does not wait on a measured size.
const totals = new Map<string, number>();
for (const l of data.links) {
totals.set(l.source, (totals.get(l.source) ?? 0) + l.value);
}
const incoming = new Map<string, number>();
for (const l of data.links) incoming.set(l.target, (incoming.get(l.target) ?? 0) + l.value);
const labels = new Map(data.nodes.map((n) => [n.id, n.label]));
const flows = data.links.map((l) => {
const sourceValue = Math.max(totals.get(l.source) ?? 0, incoming.get(l.source) ?? 0);
return {
id: l.id ?? `${l.source}:${l.target}`,
source: labels.get(l.source) ?? l.source,
target: labels.get(l.target) ?? l.target,
value: l.value,
share: sourceValue ? l.value / sourceValue : 0,
};
});
const empty = !data.links.length ? (nodes.length ? "No flows to show" : "No sankey data") : null;
return {
...layout,
flows,
empty,
resized,
size,
setSize,
nodeWidth,
label,
formatValue,
active,
setActive,
cursor,
tooltipOpen,
setTooltipOpen,
tooltipId: useId(),
gradientId: useId(),
plotRef,
buttons,
registerButton,
reduce: useReducedMotion() ?? false,
canHover: useHoverCapable(),
};
}
export type SankeyModel = ReturnType<typeof useSankeyModel>;
export const SankeyContext = createContext<SankeyModel | null>(null);
export function useSankeyChart() {
const model = useContext(SankeyContext);
if (!model) throw new Error("Sankey chart parts must be rendered inside SankeyChart.");
return model;
}
/** Whether a node or link is part of what is highlighted. */
export function emphasis(active: SankeyActive | null) {
const node = (n: PlacedNode) => {
if (!active) return true;
if (active.type === "link") return active.link.source === n || active.link.target === n;
if (active.node === n) return true;
return false;
};
const link = (l: PlacedLink) => {
if (!active) return true;
if (active.type === "link") return active.link === l;
return l.source === active.node || l.target === active.node;
};
return { node, link };
}
export interface SankeyNode {
id: string;
label: string;
/** Any CSS color. Defaults to the palette, in node order. */
color?: string;
description?: string;
}
export interface SankeyLink {
/** Defaults to `${source}:${target}`. */
id?: string;
source: string;
target: string;
/** Positive value. Zero, negative and invalid values are left out. */
value: number;
}
export type PlacedNode = Required<Pick<SankeyNode, "id" | "label" | "color">> & {
description?: string;
/** Larger of the node's incoming and outgoing totals. */
value: number;
inValue: number;
outValue: number;
/** Column, counted from the sources. */
depth: number;
x0: number;
x1: number;
y0: number;
y1: number;
};
export type PlacedLink = {
id: string;
source: PlacedNode;
target: PlacedNode;
value: number;
/** Share of the source node's value, from 0 to 1. */
share: number;
/** Band thickness. */
width: number;
/** Centerline ends: leaving the source and arriving at the target. */
x0: number;
y0: number;
x1: number;
y1: number;
/** Centerline cubic path; stroke it at `width`. */
path: string;
};
export const SANKEY_COLORS = [
"#1d4ed8",
"#7c3aed",
"#0f766e",
"#b45309",
"#be123c",
"#475569",
] as const;
/** Relaxation passes; more settles crossings further at a small cost. */
const ITERATIONS = 6;
export const linkId = (link: SankeyLink) => link.id ?? `${link.source}:${link.target}`;
export type SankeyData = {
nodes: SankeyNode[];
links: SankeyLink[];
/** Links left out, by reason, for the development warning. */
dropped: { invalid: SankeyLink[]; cycle: SankeyLink[] };
};
/**
* Cleans consumer data once per change, not per resize:
* - repeated node ids keep the first node;
* - links to unknown nodes, self links and non-positive or non-finite values
* are dropped;
* - unnamed links between the same pair are summed into one band, while a
* repeated explicit id keeps the first link;
* - a link closing a cycle is dropped: one whose target is still on the
* depth-first path that reached its source, so the first-listed direction
* of a loop survives.
*/
export function cleanSankey(
nodes: readonly SankeyNode[],
links: readonly SankeyLink[],
): SankeyData {
const byId = new Map<string, SankeyNode>();
for (const node of nodes) if (!byId.has(node.id)) byId.set(node.id, node);
const unique = [...byId.values()];
const invalid: SankeyLink[] = [];
const merged = new Map<string, SankeyLink>();
for (const link of links) {
const ok =
byId.has(link.source) &&
byId.has(link.target) &&
link.source !== link.target &&
Number.isFinite(link.value) &&
link.value > 0;
if (!ok) {
invalid.push(link);
continue;
}
const id = linkId(link);
const existing = merged.get(id);
if (!existing) merged.set(id, { ...link, id });
else if (link.id === undefined && existing.source === link.source && existing.target === link.target)
existing.value += link.value;
else invalid.push(link);
}
const valid = [...merged.values()];
const outgoing = new Map<string, SankeyLink[]>();
for (const link of valid) {
outgoing.set(link.source, [...(outgoing.get(link.source) ?? []), link]);
}
const state = new Map<string, "open" | "done">();
const back = new Set<SankeyLink>();
// Iterative depth-first search, so a very long chain cannot overflow the stack.
for (const root of unique) {
if (state.has(root.id)) continue;
const stack: { id: string; next: number }[] = [{ id: root.id, next: 0 }];
state.set(root.id, "open");
while (stack.length) {
const frame = stack[stack.length - 1];
const edges = outgoing.get(frame.id) ?? [];
if (frame.next >= edges.length) {
state.set(frame.id, "done");
stack.pop();
continue;
}
const link = edges[frame.next++];
const seen = state.get(link.target);
if (seen === "open") back.add(link);
else if (seen === undefined) {
state.set(link.target, "open");
stack.push({ id: link.target, next: 0 });
}
}
}
return {
nodes: unique,
links: valid.filter((link) => !back.has(link)),
dropped: { invalid, cycle: valid.filter((link) => back.has(link)) },
};
}
/**
* Lays nodes out in columns by their longest path from a source, with sinks
* pushed to the last column, then sizes them to their value and relaxes each
* column toward its neighbours to shorten the links. Each column keeps the
* order the nodes were given in, so order them to avoid crossings.
*/
export function layoutSankey(
{ nodes, links: kept }: SankeyData,
{
width,
height,
nodeWidth,
nodePadding,
colors,
}: {
width: number;
height: number;
nodeWidth: number;
nodePadding: number;
colors: readonly string[];
},
) {
const inValue = new Map<string, number>();
const outValue = new Map<string, number>();
for (const link of kept) {
outValue.set(link.source, (outValue.get(link.source) ?? 0) + link.value);
inValue.set(link.target, (inValue.get(link.target) ?? 0) + link.value);
}
// Longest-path depth over the acyclic links, in topological order.
const indegree = new Map<string, number>();
for (const link of kept) indegree.set(link.target, (indegree.get(link.target) ?? 0) + 1);
const depth = new Map<string, number>();
const queue = nodes.filter((node) => !indegree.get(node.id)).map((node) => node.id);
for (const id of queue) depth.set(id, 0);
for (let i = 0; i < queue.length; i++) {
const id = queue[i];
for (const link of kept) {
if (link.source !== id) continue;
depth.set(link.target, Math.max(depth.get(link.target) ?? 0, (depth.get(id) ?? 0) + 1));
const left = (indegree.get(link.target) ?? 0) - 1;
indegree.set(link.target, left);
if (left === 0) queue.push(link.target);
}
}
const linked = nodes.filter((node) => inValue.has(node.id) || outValue.has(node.id));
if (!linked.length || width <= 0 || height <= 0) {
return { nodes: [] as PlacedNode[], links: [] as PlacedLink[], columns: 0, step: 0 };
}
const maxDepth = Math.max(0, ...linked.map((node) => depth.get(node.id) ?? 0));
const columns = maxDepth + 1;
const step = columns > 1 ? (width - nodeWidth) / (columns - 1) : 0;
const placed: PlacedNode[] = linked.map((node) => {
const index = nodes.indexOf(node);
const value = Math.max(inValue.get(node.id) ?? 0, outValue.get(node.id) ?? 0);
// Sinks sit in the last column, so every outcome lines up on the right.
const d = outValue.has(node.id) ? (depth.get(node.id) ?? 0) : maxDepth;
return {
id: node.id,
label: node.label,
description: node.description,
color: node.color ?? colors[index % colors.length],
value,
inValue: inValue.get(node.id) ?? 0,
outValue: outValue.get(node.id) ?? 0,
depth: d,
x0: d * step,
x1: d * step + nodeWidth,
y0: 0,
y1: 0,
};
});
const byId = new Map(placed.map((node) => [node.id, node]));
const byColumn = Array.from({ length: columns }, (_, d) =>
placed.filter((node) => node.depth === d),
);
// One scale for every column: the fullest column fills the height.
const padding = Math.min(
nodePadding,
...byColumn.map((column) =>
column.length > 1 ? (height * 0.5) / (column.length - 1) : nodePadding,
),
);
const ky = Math.min(
...byColumn
.filter((column) => column.length)
.map(
(column) =>
(height - (column.length - 1) * padding) /
column.reduce((sum, node) => sum + node.value, 0),
),
);
for (const column of byColumn) {
let y = 0;
for (const node of column) {
node.y0 = y;
node.y1 = y + node.value * ky;
y = node.y1 + padding;
}
// Center columns shorter than the fullest one.
const offset = (height - (y - padding)) / 2;
for (const node of column) {
node.y0 += offset;
node.y1 += offset;
}
}
// Columns keep the order nodes were given in, so the same data in a new
// period morphs in place instead of swapping nodes past each other.
// Relaxation only moves a node within the room its neighbours leave.
const resolve = (column: PlacedNode[]) => {
let y = 0;
for (const node of column) {
const dy = y - node.y0;
if (dy > 0) {
node.y0 += dy;
node.y1 += dy;
}
y = node.y1 + padding;
}
y = height;
for (let i = column.length - 1; i >= 0; i--) {
const node = column[i];
const dy = node.y1 - y;
if (dy > 0) {
node.y0 -= dy;
node.y1 -= dy;
}
y = node.y0 - padding;
}
};
const center = (node: PlacedNode) => (node.y0 + node.y1) / 2;
const relax = (column: PlacedNode[], alpha: number, side: "source" | "target") => {
for (const node of column) {
let weighted = 0;
let total = 0;
for (const link of kept) {
const self = side === "source" ? link.target : link.source;
if (self !== node.id) continue;
const other = byId.get(side === "source" ? link.source : link.target);
if (!other) continue;
weighted += center(other) * link.value;
total += link.value;
}
if (!total) continue;
const dy = (weighted / total - center(node)) * alpha;
node.y0 += dy;
node.y1 += dy;
}
resolve(column);
};
for (let i = 0; i < ITERATIONS; i++) {
const alpha = 0.99 ** i;
for (let d = 1; d < columns; d++) relax(byColumn[d], alpha, "source");
for (let d = columns - 2; d >= 0; d--) relax(byColumn[d], alpha, "target");
}
// Stack each node's links in the order of the nodes they reach, so bands
// leave and arrive without crossing at the node.
const placedLinks = kept.map((link) => ({
link,
source: byId.get(link.source) as PlacedNode,
target: byId.get(link.target) as PlacedNode,
sy: 0,
ty: 0,
}));
for (const node of placed) {
let sy = node.y0;
for (const entry of placedLinks
.filter((entry) => entry.source === node)
.sort((a, b) => a.target.y0 - b.target.y0)) {
entry.sy = sy;
sy += entry.link.value * ky;
}
let ty = node.y0;
for (const entry of placedLinks
.filter((entry) => entry.target === node)
.sort((a, b) => a.source.y0 - b.source.y0)) {
entry.ty = ty;
ty += entry.link.value * ky;
}
}
const result: PlacedLink[] = placedLinks.map(({ link, source, target, sy, ty }) => {
const w = link.value * ky;
const x0 = source.x1;
const x1 = target.x0;
const y0 = sy + w / 2;
const y1 = ty + w / 2;
const xm = (x0 + x1) / 2;
return {
id: linkId(link),
source,
target,
value: link.value,
share: source.value ? link.value / source.value : 0,
width: w,
x0,
y0,
x1,
y1,
path: `M ${x0} ${y0} C ${xm} ${y0} ${xm} ${y1} ${x1} ${y1}`,
};
});
// Reading order for keyboard focus: column by column, top to bottom.
const ordered = byColumn.flat();
return { nodes: ordered, links: result, columns, step };
}
"use client";
/* Links draw in from left to right once the plot scrolls into view, a column
* at a time, then nodes fade up at their full size. When the data changes,
* links and nodes spring to their new geometry; a resize re-lays them at
* once. Hovering or focusing a node lights it, its flows and the nodes they
* reach; hovering a flow lights that flow and its two ends; everything else
* fades. Reduced motion: the chart appears in place and changes without
* easing. */
import { motion, useInView } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { emphasis, useSankeyChart } from "./context";
import type { PlacedNode } from "./model";
// Link draw-in: each column starts this long after the one before it.
const COLUMN_DELAY = 0.12;
const DRAW_DURATION = 0.7;
// Nodes shorter than this keep their name in the tooltip and table only.
const MIN_LABEL_HEIGHT = 12;
// Gap between a node bar and its label, in px.
const LABEL_GAP = 8;
// Average glyph width of the 12px label face, for fitting labels to a column.
const LABEL_CHAR_WIDTH = 6.6;
// Pointer target around each node bar, and its smallest height (WCAG 2.5.8).
const HIT_SLOP = 4;
const MIN_HIT = 24;
// Flows thinner than this still get this much hover target.
const MIN_LINK_HIT = 8;
export interface SankeyChartPlotProps {
/** Sets the plot height; default 20rem. The layout follows its measured size. */
className?: string;
/** Show each node's value under its label when there is room. */
showValues?: boolean;
}
/** Fits a label to `room` px, ending in an ellipsis; null when nothing fits. */
function fit(label: string, room: number) {
const chars = Math.floor(room / LABEL_CHAR_WIDTH);
if (chars >= label.length) return label;
if (chars < 4) return null;
return `${label.slice(0, chars - 1).trimEnd()}…`;
}
export function SankeyChartPlot({ className, showValues = true }: SankeyChartPlotProps) {
const {
nodes,
links,
columns,
step,
nodeWidth,
size,
setSize,
resized,
active,
setActive,
cursor,
tooltipOpen,
setTooltipOpen,
tooltipId,
gradientId,
plotRef,
buttons,
registerButton,
formatValue,
label,
empty,
reduce,
canHover,
} = useSankeyChart();
const inView = useInView(plotRef, { once: true, amount: 0.3 });
const shown = reduce || inView;
const [entered, setEntered] = useState(false);
const focusedId = useRef<string | null>(null);
// useId output can hold characters that break a url(#…) reference.
const gradient = gradientId.replace(/[^\w-]/g, "");
useLayoutEffect(() => {
const plot = plotRef.current;
if (!plot) return;
const measure = () => {
const { width, height } = plot.getBoundingClientRect();
if (width > 0 && height > 0)
setSize((s) => (s.width === width && s.height === height ? s : { width, height }));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(plot);
return () => observer.disconnect();
}, [plotRef, setSize]);
// A focused node that leaves the data hands focus to the tab stop, so
// keyboard users are not dropped to the top of the page.
useLayoutEffect(() => {
const id = focusedId.current;
if (!id || nodes.some((node) => node.id === id)) return;
focusedId.current = null;
const lost = document.activeElement;
if (lost === document.body || !plotRef.current?.contains(lost)) {
const replacement = nodes[Math.max(cursor.activeIndex, 0)];
if (replacement) buttons.current.get(replacement.id)?.focus();
else plotRef.current?.focus();
}
}, [nodes, cursor.activeIndex, buttons, plotRef]);
// A tap or click elsewhere dismisses what a tap opened; touch has no hover
// to leave, so the tooltip would otherwise stay up.
useEffect(() => {
if (!tooltipOpen) return;
const dismiss = (event: globalThis.PointerEvent) => {
if (plotRef.current?.contains(event.target as Node)) return;
setTooltipOpen(false);
setActive(null);
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [tooltipOpen, plotRef, setTooltipOpen, setActive]);
// A node with any flow to or from the highlighted node lights up with it.
const lit = emphasis(active);
const neighbours = new Set<PlacedNode>();
if (active?.type === "node") {
for (const link of links) {
if (link.source === active.node) neighbours.add(link.target);
if (link.target === active.node) neighbours.add(link.source);
}
}
const nodeLit = (node: PlacedNode) => lit.node(node) || neighbours.has(node);
const tabStop = nodes[Math.max(cursor.activeIndex, 0)]?.id;
const ref = useCallback(
(id: string) => (element: HTMLButtonElement | null) => registerButton(id, element),
[registerButton],
);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (!(target instanceof HTMLButtonElement)) return;
const current = nodes.find((node) => node.id === target.dataset.sankeyNode);
if (!current) return;
if (event.key === "Escape") {
if (!tooltipOpen) return;
event.preventDefault();
setTooltipOpen(false);
return;
}
const center = (node: PlacedNode) => (node.y0 + node.y1) / 2;
const column = (depth: number) => nodes.filter((node) => node.depth === depth);
let next: PlacedNode | undefined;
if (event.key === "Home") next = nodes[0];
else if (event.key === "End") next = nodes[nodes.length - 1];
else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
const same = column(current.depth);
next = same[same.indexOf(current) + (event.key === "ArrowUp" ? -1 : 1)];
} else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
// Nearest node by height in the next populated column that way.
const dir = event.key === "ArrowLeft" ? -1 : 1;
for (let d = current.depth + dir; d >= 0 && d < columns && !next; d += dir) {
next = column(d).reduce<PlacedNode | undefined>(
(best, node) =>
!best ||
Math.abs(center(node) - center(current)) < Math.abs(center(best) - center(current))
? node
: best,
undefined,
);
}
} else return;
event.preventDefault();
setTooltipOpen(true);
if (next && next !== current) buttons.current.get(next.id)?.focus();
else setActive(current.id);
};
// Enter alone can be skipped when the pointer jumps straight from one part
// to another, so moves claim the highlight too; same-value updates bail out.
// A touch only claims it while pressed, so a scroll does not leave it lit.
const point = (event: PointerEvent<Element>, id: string) => {
if (event.pointerType === "touch" ? event.buttons !== 1 : !canHover) return;
setActive(id);
setTooltipOpen(true);
};
const press = (id: string) => {
setActive(id);
setTooltipOpen(true);
};
const geometry = reduce || resized ? { duration: 0 } : SPRING_LAYOUT;
const linkOpacity = (lightUp: boolean) => (active ? (lightUp ? 0.6 : 0.08) : 0.32);
// Room for a label between its node and the next column's nodes.
const labelRoom = columns > 1 ? step - nodeWidth - LABEL_GAP * 2 : size.width;
// Thin flows' hit paths go on top, so a thick neighbour cannot bury them.
const hitOrder = [...links].sort((a, b) => b.width - a.width);
return (
// Scrolls sideways under 32rem. The padding, cancelled by the margin,
// holds the node hit areas and focus rings that reach past the plot, so
// the scroller never clips them or scrolls vertically.
<div className="-m-2 overflow-x-auto overflow-y-hidden p-2">
{/* biome-ignore lint/a11y/useSemanticElements: This group is a chart, not a collection of form fields. */}
<div
ref={plotRef}
role="group"
aria-label={`${label} nodes`}
tabIndex={-1}
data-slot="sankey-chart-plot"
className={cn("relative h-80 w-full min-w-[32rem] outline-none", className)}
onKeyDown={onKeyDown}
onFocus={(event) => {
if (event.target instanceof HTMLButtonElement)
focusedId.current = event.target.dataset.sankeyNode ?? null;
}}
onBlur={(event) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
focusedId.current = null;
setTooltipOpen(false);
setActive(null);
}}
onPointerLeave={(event) => {
// A lifted finger leaves too; touch dismisses by tapping elsewhere.
if (event.pointerType === "touch") return;
setTooltipOpen(false);
setActive(null);
}}
>
<svg
width={size.width}
height={size.height}
viewBox={`0 0 ${size.width} ${size.height}`}
aria-hidden="true"
className="absolute inset-0 overflow-visible"
>
<defs>
{links.map((link, i) => (
<linearGradient
key={link.id}
id={`${gradient}-${i}`}
gradientUnits="userSpaceOnUse"
x1={link.x0}
x2={link.x1}
>
<stop offset="0%" stopColor={link.source.color} />
<stop offset="100%" stopColor={link.target.color} />
</linearGradient>
))}
</defs>
<g fill="none" pointerEvents="none">
{links.map((link, i) => (
<motion.path
key={link.id}
stroke={`url(#${gradient}-${i})`}
strokeWidth={Math.max(1, link.width)}
initial={reduce ? false : { d: link.path, pathLength: 0, opacity: 0.32 }}
animate={
shown
? { d: link.path, pathLength: 1, opacity: linkOpacity(lit.link(link)) }
: undefined
}
transition={
reduce
? { duration: 0 }
: {
d: geometry,
pathLength: {
duration: DRAW_DURATION,
ease: EASE_OUT,
delay: entered ? 0 : link.source.depth * COLUMN_DELAY,
},
opacity: { duration: 0.16, ease: EASE_OUT },
}
}
onAnimationComplete={() => setEntered(true)}
/>
))}
</g>
{/* Static hit paths own hover: never animated, at least MIN_LINK_HIT
wide, and on the final geometry so the target never drifts. */}
<g fill="none" stroke="transparent">
{hitOrder.map((link) => (
<path
key={link.id}
d={link.path}
strokeWidth={Math.max(MIN_LINK_HIT, link.width)}
pointerEvents={shown ? "stroke" : "none"}
className="cursor-pointer"
onPointerEnter={(event) => point(event, link.id)}
onPointerMove={(event) => point(event, link.id)}
onPointerDown={() => press(link.id)}
/>
))}
</g>
<g pointerEvents="none">
{nodes.map((node) => (
<motion.rect
key={node.id}
x={node.x0}
width={node.x1 - node.x0}
rx={2}
fill={node.color}
initial={reduce ? false : { y: node.y0, height: node.y1 - node.y0, opacity: 0 }}
animate={
shown
? {
y: node.y0,
height: Math.max(1, node.y1 - node.y0),
opacity: nodeLit(node) ? 1 : 0.3,
}
: undefined
}
transition={
reduce
? { duration: 0 }
: {
y: geometry,
height: geometry,
opacity: {
duration: entered ? 0.16 : 0.3,
ease: EASE_OUT,
delay: entered ? 0 : 0.1 + node.depth * COLUMN_DELAY,
},
}
}
/>
))}
</g>
<g className="text-xs" pointerEvents="none">
{nodes.map((node) => {
const h = node.y1 - node.y0;
if (h < MIN_LABEL_HEIGHT) return null;
const text = fit(node.label, labelRoom);
if (!text) return null;
const last = columns > 1 && node.depth === columns - 1;
const x = last ? node.x0 - LABEL_GAP : node.x1 + LABEL_GAP;
const value = formatValue(node.value);
const twoLines =
showValues &&
h >= MIN_LABEL_HEIGHT * 2.4 &&
value.length * LABEL_CHAR_WIDTH <= labelRoom;
return (
<motion.text
key={node.id}
x={x}
textAnchor={last ? "end" : "start"}
dominantBaseline="middle"
// A halo in the page colour keeps labels readable over the bands.
className="fill-foreground stroke-background [paint-order:stroke]"
strokeWidth={3}
strokeLinejoin="round"
initial={reduce ? false : { y: (node.y0 + node.y1) / 2, opacity: 0 }}
animate={
shown
? { y: (node.y0 + node.y1) / 2, opacity: nodeLit(node) ? 1 : 0.35 }
: undefined
}
transition={
reduce
? { duration: 0 }
: {
y: geometry,
opacity: {
duration: 0.2,
ease: EASE_OUT,
delay: entered ? 0 : 0.2 + node.depth * COLUMN_DELAY,
},
}
}
>
<tspan x={x} dy={twoLines ? "-0.6em" : 0} className="font-medium">
{text}
</tspan>
{twoLines ? (
<tspan x={x} dy="1.2em" className="fill-muted-foreground tabular-nums">
{value}
</tspan>
) : null}
</motion.text>
);
})}
</g>
</svg>
{nodes.map((node) => {
const selected = tooltipOpen && active?.type === "node" && active.node === node;
const h = Math.max(1, node.y1 - node.y0);
const hit = Math.max(MIN_HIT, h + HIT_SLOP * 2);
return (
<button
key={node.id}
ref={ref(node.id)}
type="button"
data-sankey-node={node.id}
tabIndex={node.id === tabStop ? 0 : -1}
aria-label={[
node.label,
formatValue(node.value),
node.inValue && node.outValue
? `in ${formatValue(node.inValue)}, out ${formatValue(node.outValue)}`
: null,
node.description,
]
.filter(Boolean)
.join(". ")}
aria-describedby={selected ? tooltipId : undefined}
className="absolute rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{
left: node.x0 - HIT_SLOP,
// Centered on the bar, so a sliver of a node still gets a full target.
top: node.y0 + h / 2 - hit / 2,
width: node.x1 - node.x0 + HIT_SLOP * 2,
height: hit,
pointerEvents: shown ? "auto" : "none",
}}
onPointerEnter={(event) => point(event, node.id)}
onPointerMove={(event) => point(event, node.id)}
onPointerDown={() => press(node.id)}
onFocus={() => {
cursor.moveTo(node.id);
setActive(node.id);
setTooltipOpen(true);
}}
/>
);
})}
{empty ? (
<p className="flex h-full items-center justify-center text-sm text-muted-foreground">
{empty}
</p>
) : null}
</div>
</div>
);
}
"use client";
import { MotionConfig } from "motion/react";
import type { ReactNode } from "react";
import { NumberTicker } from "@/components/motion/number-ticker";
import { Tooltip } from "@/components/motion/tooltip";
import { cn } from "@/lib/utils";
import { type SankeyActive, useSankeyChart } from "./context";
// Flows listed per direction in a node's tooltip.
const MAX_FLOWS = 4;
export interface SankeyChartTooltipProps {
className?: string;
side?: "top" | "right" | "bottom" | "left";
/** Custom content for the highlighted node or flow. */
children?: ReactNode | ((active: SankeyActive) => ReactNode);
}
function Ticker({ value, format }: { value: number; format: (value: number) => string }) {
return (
<NumberTicker
value={value}
format={() => format(value)}
startOnView={false}
duration={0.2}
stagger={0}
/>
);
}
export function SankeyChartTooltip({ className, side = "top", children }: SankeyChartTooltipProps) {
const {
active,
nodes,
links,
size,
plotRef,
tooltipId,
tooltipOpen,
setTooltipOpen,
formatValue,
} = useSankeyChart();
if (!nodes.length) return null;
// Anchors keyboard focus to the top of the node, so the tooltip clears its
// label, or to the flow's midpoint; the pointer takes over while it moves.
const point =
active?.type === "node"
? { x: (active.node.x0 + active.node.x1) / 2, y: active.node.y0 }
: active?.type === "link"
? { x: (active.link.x0 + active.link.x1) / 2, y: (active.link.y0 + active.link.y1) / 2 }
: { x: size.width / 2, y: size.height / 2 };
const percent = (value: number) => `${(value * 100).toFixed(1)}%`;
return (
<Tooltip
id={tooltipId}
anchorRef={plotRef}
followCursor
anchorPoint={{ x: point.x / size.width, y: point.y / size.height }}
open={tooltipOpen && active !== null}
onOpenChange={setTooltipOpen}
side={side}
className={cn("max-w-64", className)}
content={
active ? (
typeof children === "function" ? (
children(active)
) : (
(children ?? (
<MotionConfig transition={{ type: "tween" }}>
{active.type === "node" ? (
<span className="flex flex-col gap-1.5 text-xs">
<span className="inline-flex items-center gap-2 font-medium">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ backgroundColor: active.node.color }}
/>
{active.node.label}
</span>
<span className="font-mono tabular-nums">
<Ticker value={active.node.value} format={formatValue} />
</span>
{active.node.inValue && active.node.outValue ? (
<span className="font-mono text-muted-foreground tabular-nums">
In {formatValue(active.node.inValue)} · Out{" "}
{formatValue(active.node.outValue)}
</span>
) : null}
{(["in", "out"] as const).map((direction) => {
// A node's largest flows that way; the table has the rest.
const own = links
.filter((l) =>
direction === "in" ? l.target === active.node : l.source === active.node,
)
.sort((a, b) => b.value - a.value);
if (!own.length) return null;
return (
<span key={direction} className="flex flex-col gap-0.5">
<span className="text-muted-foreground">
{direction === "in" ? "From" : "To"}
</span>
{own.slice(0, MAX_FLOWS).map((l) => (
<span key={l.id} className="flex items-center justify-between gap-3">
<span className="truncate">
{direction === "in" ? l.source.label : l.target.label}
</span>
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
{formatValue(l.value)}
</span>
</span>
))}
{own.length > MAX_FLOWS ? (
<span className="text-muted-foreground">
{own.length - MAX_FLOWS} more
</span>
) : null}
</span>
);
})}
{active.node.description ? (
<span className="text-muted-foreground">{active.node.description}</span>
) : null}
</span>
) : (
<span className="flex flex-col gap-1.5 text-xs">
<span className="font-medium">
{active.link.source.label} → {active.link.target.label}
</span>
<span className="inline-flex items-center gap-2 font-mono tabular-nums">
<Ticker value={active.link.value} format={formatValue} />
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
<Ticker value={active.link.share} format={percent} /> of{" "}
{active.link.source.label}
</span>
</span>
</span>
)}
</MotionConfig>
))
)
) : null
}
/>
);
}
"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>
);
}
"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>
);
}
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>
);
}
"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>
);
}
API Reference
SankeyChart
nodesreadonly SankeyNode[]—linksreadonly SankeyLink[]Weighted flows between node ids. Links that would close a loop are left out.
—label?string—formatValue?((value: number) => string)—colors?readonly string[]Palette for nodes without their own color, in node order.
—nodeWidth?numberNode bar width in px.
—nodePadding?numberSpace between nodes in a column, in px.
—activeId?string | nullHighlighted node or link id. Controlled. Link ids default to `source:target`.
—defaultActiveId?string | null—onActiveChange?((id: string | null) => void)—className?string—SankeyChartPlot
className?stringSets the plot height; default 20rem. The layout follows its measured size.
—showValues?booleanShow each node's value under its label when there is room.
trueSankeyChartTooltip
className?string—side?"bottom" | "left" | "right" | "top"topchildren?ReactNode | ((active: SankeyActive) => ReactNode)Custom content for the highlighted node or flow.
—Related components
Treemap
Proportional tiles that morph with your data, with value and share tooltips.
Funnel Chart
Vertical and horizontal funnels with curved stages and animated number tooltips.
Returns Calendar
Composable monthly returns calendar with Grid and Tooltip parts, controlled month or year selections, and compounded totals. Supply your own years and monthly percentages.
Sankey Chart is an animated React Sankey diagram for weighted flows between stages, built with Framer Motion and Tailwind CSS. You pass nodes and links between their ids; the chart places nodes in columns by how far they sit from a source, sizes each one to its value and arranges every column to keep bands short and uncrossed. Links draw in from left to right when the chart scrolls into view and spring to new positions when the data changes, and hovering or focusing a node or flow highlights everything it connects.
When to use it
- A finance page shows how revenue breaks down into costs, expenses and profit.
- A product dashboard follows users from signup sources through activation to plans.
- An energy, budget or supply report shows where an amount comes from and where it goes.
Accessibility
The chart is a labelled region, and each node is a native button named by its label, value and in and out totals, with one roving tab stop. Arrow Up and Arrow Down move within a column, Arrow Left and Arrow Right jump to the nearest node in the next column, Home and End reach the first and last node, and Escape closes the tooltip. A focused node's tooltip lists its largest incoming and outgoing flows, and a visually hidden table lists every flow with its exact value and share of its source. Every node keeps a pointer target at least 24px tall, and thin flows keep an 8px hover target. If a focused node leaves the data, focus moves to the remaining tab stop. With reduced motion enabled, flows appear in place and update without the draw-in or spring.
FAQ
- How do I make a Sankey diagram in React?
- Install the component and pass nodes, each with an id and label, and links with a source id, a target id and a positive value. Columns, node sizes and band positions are computed from the links.
- What happens with loops or bad links?
- Links to unknown nodes, zero or negative values, links from a node to itself and links that would close a loop are left out, so the chart always draws a left-to-right flow, and the first listed direction of a loop is kept. Repeated node ids keep the first node, and unnamed links between the same two nodes are added together into one band. In development, the chart logs what it left out.
- Can I control what is highlighted?
- Yes. Pass activeId with a node id, or a link id that defaults to source:target, and onActiveChange to follow hover and focus. Use defaultActiveId to start with something highlighted.
Updated