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.
Preview
"use client";
import {
ReturnsCalendar,
ReturnsCalendarGrid,
ReturnsCalendarTooltip,
} from "@/components/charts/returns-calendar";
const MONTHS = Array.from({ length: 12 });
const DEFAULT_YEARS = [2021, 2022, 2023, 2024, 2025];
/** Deterministic sample field so every render agrees; 2022 reads as a down year. */
const DEFAULT_RETURNS: number[][] = (() => {
let seed = 2021;
const rnd = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
return DEFAULT_YEARS.map((_, yi) =>
MONTHS.map(() => Math.round(((yi === 1 ? -1.6 : 0.9) + (rnd() - 0.5) * 12) * 10) / 10),
);
})();
export function ReturnsCalendarPreview() {
return (
<ReturnsCalendar years={DEFAULT_YEARS} returns={DEFAULT_RETURNS}>
<ReturnsCalendarGrid>
<ReturnsCalendarTooltip />
</ReturnsCalendarGrid>
</ReturnsCalendar>
);
}
"use client";
// beui.dev/charts/returns-calendar
import { cn } from "@/lib/utils";
import { ReturnsCalendarContext, useReturnsCalendarModel } from "./returns-calendar/context";
import { ReturnsCalendarGrid } from "./returns-calendar/grid";
import { ReturnsCalendarTooltip } from "./returns-calendar/tooltip";
import type { ReturnsCalendarProps } from "./returns-calendar/types";
/** Compose Grid and Tooltip, or omit children for the complete chart. */
export function ReturnsCalendar({ children, className, ...props }: ReturnsCalendarProps) {
const model = useReturnsCalendarModel(props);
return (
<ReturnsCalendarContext.Provider value={model}>
<div className={cn("w-[480px] max-w-full [--ink-l:0.5] dark:[--ink-l:1]", className)}>
{children === undefined ? (
<ReturnsCalendarGrid>
<ReturnsCalendarTooltip />
</ReturnsCalendarGrid>
) : (
children
)}
</div>
</ReturnsCalendarContext.Provider>
);
}
export { useReturnsCalendar } from "./returns-calendar/context";
export { ReturnsCalendarGrid } from "./returns-calendar/grid";
export { ReturnsCalendarTooltip } from "./returns-calendar/tooltip";
export type {
ReturnsCalendarProps,
ReturnsCalendarSelection,
ReturnsCalendarTooltipData,
} from "./returns-calendar/types";
// A local type alias keeps docgen from listing this data shape as a component.
export type ReturnsCalendarCell = import("./returns-calendar/types").ReturnsCalendarCell;
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 motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | SVGElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/charts/returns-calendar
import { cn } from "@/lib/utils";
import { ReturnsCalendarContext, useReturnsCalendarModel } from "./returns-calendar/context";
import { ReturnsCalendarGrid } from "./returns-calendar/grid";
import { ReturnsCalendarTooltip } from "./returns-calendar/tooltip";
import type { ReturnsCalendarProps } from "./returns-calendar/types";
/** Compose Grid and Tooltip, or omit children for the complete chart. */
export function ReturnsCalendar({ children, className, ...props }: ReturnsCalendarProps) {
const model = useReturnsCalendarModel(props);
return (
<ReturnsCalendarContext.Provider value={model}>
<div className={cn("w-[480px] max-w-full [--ink-l:0.5] dark:[--ink-l:1]", className)}>
{children === undefined ? (
<ReturnsCalendarGrid>
<ReturnsCalendarTooltip />
</ReturnsCalendarGrid>
) : (
children
)}
</div>
</ReturnsCalendarContext.Provider>
);
}
export { useReturnsCalendar } from "./returns-calendar/context";
export { ReturnsCalendarGrid } from "./returns-calendar/grid";
export { ReturnsCalendarTooltip } from "./returns-calendar/tooltip";
export type {
ReturnsCalendarProps,
ReturnsCalendarSelection,
ReturnsCalendarTooltipData,
} from "./returns-calendar/types";
// A local type alias keeps docgen from listing this data shape as a component.
export type ReturnsCalendarCell = import("./returns-calendar/types").ReturnsCalendarCell;
"use client";
import { useReducedMotion } from "motion/react";
import {
createContext,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import type { ReturnsCalendarCell, ReturnsCalendarProps, ReturnsCalendarSelection } from "./types";
import { compound, MONTHS, same, YEAR } from "./utils";
/**
* Monthly returns as a years by months grid. Each cell tints toward the up or
* down color by magnitude, and a compounded year column closes every row.
* Cells spring in on a diagonal wave. Hovering a month glides a tooltip with
* its value and dims everything that shares neither its row nor its column;
* hovering a year total replays that row month by month. Clicking a month
* anchors a span: the grid dims, hovering previews the run from that month to
* the pointer with its compounded return, a second click locks it and a third
* clears it. The Year column selects the same way but by whole years — anchor a
* year, a second year spans every month between them. Hover lifts are gated to
* pointer devices, and reduced motion keeps the fades only.
*/
export function useReturnsCalendarModel({
years = [],
returns = [],
selection: controlledSelection,
defaultSelection = null,
onSelectionChange,
}: ReturnsCalendarProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [storedHover, setHover] = useState<ReturnsCalendarCell | null>(null);
const [internalSelection, setInternalSelection] = useState(defaultSelection);
const requestedSelection = controlledSelection === undefined ? internalSelection : controlledSelection;
const validCell = (cell: ReturnsCalendarCell) =>
Number.isInteger(cell.y) &&
Number.isInteger(cell.m) &&
cell.y >= 0 &&
cell.y < years.length &&
cell.m >= 0 &&
cell.m <= YEAR;
const selection =
requestedSelection &&
validCell(requestedSelection.start) &&
(!requestedSelection.end ||
(validCell(requestedSelection.end) &&
(requestedSelection.start.m === YEAR) === (requestedSelection.end.m === YEAR)))
? requestedSelection
: null;
if (requestedSelection && !selection && controlledSelection === undefined) setInternalSelection(null);
const hover = storedHover && validCell(storedHover) ? storedHover : null;
if (storedHover && !hover) setHover(null);
const pinned = selection?.start ?? null;
const spanEnd = selection?.end ?? null;
const setSelection = (next: ReturnsCalendarSelection | null) => {
if (controlledSelection === undefined) setInternalSelection(next);
onSelectionChange?.(next);
};
const gridRef = useRef<HTMLDivElement>(null);
const tooltipId = useId();
// the entrance wave owns the cells until it has landed; hover lifts take over after
const [settled, setSettled] = useState(false);
useEffect(() => {
const t = setTimeout(() => setSettled(true), reduce ? 0 : (years.length + 13) * 20 + 500);
return () => clearTimeout(t);
}, [years.length, reduce]);
// Normalize each displayed row to twelve months so missing values never
// shift the following year's positions when a span is compounded.
const rows = useMemo(
() => years.map((_, y) => MONTHS.map((_, m) => returns[y]?.[m] ?? 0)),
[years, returns],
);
const totals = useMemo(() => rows.map(compound), [rows]);
/** every month in order, so a span is one slice */
const flat = useMemo(() => rows.flat(), [rows]);
const valueAt = (y: number, m: number) => returns[y]?.[m] ?? 0;
/** percent that saturates a month's tint */
const range = 8;
// one click on a month anchors a span and dims everything else; hovering then
// previews the run from the anchor to the pointer and compounds it live, a
// second click locks it, and the next click anywhere clears it
const ci = (c: ReturnsCalendarCell) => c.y * 12 + c.m;
const clear = () => {
setSelection(null);
};
const anchored = pinned !== null && pinned.m !== YEAR;
const spanTo = spanEnd ?? (anchored && hover && hover.m !== YEAR ? hover : null);
const span =
anchored && pinned
? spanTo
? { lo: Math.min(ci(pinned), ci(spanTo)), hi: Math.max(ci(pinned), ci(spanTo)) }
: { lo: ci(pinned), hi: ci(pinned) }
: null;
const spanValue = span ? compound(flat.slice(span.lo, span.hi + 1)) : 0;
// the Year column runs its own selection: anchor a year, then a second year
// spans whole years and compounds every month between them
const yearAnchored = pinned !== null && pinned.m === YEAR;
const yearTo = spanEnd ?? (yearAnchored && hover && hover.m === YEAR ? hover : null);
const yearSpan =
yearAnchored && pinned
? { lo: Math.min(pinned.y, (yearTo ?? pinned).y), hi: Math.max(pinned.y, (yearTo ?? pinned).y) }
: null;
const yearSpanValue = yearSpan ? compound(flat.slice(yearSpan.lo * 12, (yearSpan.hi + 1) * 12)) : 0;
const select = (cell: ReturnsCalendarCell) => {
if (spanEnd || same(pinned, cell.y, cell.m)) clear();
// extend within the same track — month→month or year→year; a different track re-anchors
else if (pinned && (pinned.m === YEAR) === (cell.m === YEAR)) setSelection({ start: pinned, end: cell });
else {
setSelection({ start: cell });
}
};
/** the cell the grid reacts to: lift, labels and the year replay follow the pointer */
const hot = hover ?? spanEnd ?? pinned;
/** the cell the tooltip hangs from: a locked span keeps it on its end */
const tip = spanEnd ?? hover ?? pinned;
// the year replay is a plain-hover flourish; hold it back while a year span is being built
const sweepRow = !yearAnchored && hot?.m === YEAR ? hot.y : null;
const showSpan = span !== null && span.lo !== span.hi;
const showYearSpan = yearSpan !== null && yearSpan.lo !== yearSpan.hi;
const tipValue = showYearSpan
? yearSpanValue
: showSpan && span
? spanValue
: tip
? tip.m === YEAR
? totals[tip.y]
: valueAt(tip.y, tip.m)
: 0;
const tipLabel =
showYearSpan && yearSpan
? `${years[yearSpan.lo]} – ${years[yearSpan.hi]}`
: showSpan && span
? `${MONTHS[span.lo % 12]} ${years[Math.floor(span.lo / 12)]} – ${MONTHS[span.hi % 12]} ${years[Math.floor(span.hi / 12)]}`
: tip
? tip.m === YEAR
? `${years[tip.y]}`
: `${MONTHS[tip.m]} ${years[tip.y]}`
: "";
const tipNote =
showYearSpan && yearSpan
? `${yearSpan.hi - yearSpan.lo + 1} years`
: showSpan && span
? `${span.hi - span.lo + 1} months`
: tip?.m === YEAR
? "for the year"
: null;
const enter = (y: number, m: number) => () => setHover({ y, m });
const press = (y: number, m: number) => () => select({ y, m });
const clearOnEscape = (e: { key: string }) => {
if (e.key === "Escape") clear();
};
return {
years,
reduce,
canHover,
hover,
pinned,
spanEnd,
settled,
totals,
valueAt,
range,
clear,
span,
yearSpan,
hot,
tip,
sweepRow,
tipValue,
tipLabel,
tipNote,
enter,
press,
clearOnEscape,
setHover,
gridRef,
tooltipId,
selection,
setSelection,
};
}
export const ReturnsCalendarContext = createContext<ReturnType<typeof useReturnsCalendarModel> | null>(null);
export function useReturnsCalendar() {
const context = useContext(ReturnsCalendarContext);
if (!context) throw new Error("ReturnsCalendar parts must be inside ReturnsCalendar");
return context;
}
"use client";
import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useReturnsCalendar } from "./context";
import { COLS, DOWN, ink, MONTHS, RING, same, signed, tint, UP, YEAR } from "./utils";
export function ReturnsCalendarGrid({ children, className }: { children?: ReactNode; className?: string }) {
const {
years,
reduce,
canHover,
pinned,
settled,
totals,
valueAt,
range,
span,
yearSpan,
hot,
sweepRow,
enter,
press,
clearOnEscape,
setHover,
gridRef,
tooltipId,
} = useReturnsCalendar();
return (
<div
ref={gridRef}
className={cn("relative grid gap-1", className)}
style={{ gridTemplateColumns: "32px repeat(12, 1fr) 52px" }}
onPointerLeave={() => setHover(null)}
>
<span />
{COLS.map((c) => (
<span
key={c.name}
className={cn(
"pb-1 text-center text-[10px] transition-colors duration-200",
hot?.m === c.m ? "text-foreground" : "text-muted-foreground",
)}
>
{c.initial}
</span>
))}
<span
className={cn(
"pb-1 text-center text-[10px] transition-colors duration-200",
hot?.m === YEAR ? "text-foreground" : "text-muted-foreground",
)}
>
Year
</span>
{years.map((year, y) => (
<div key={year} className="contents">
<span
className={cn(
"flex items-center justify-end pr-1.5 font-mono text-[10px] tabular-nums transition-colors duration-200",
hot?.y === y ? "text-foreground" : "text-muted-foreground",
)}
>
{`’${String(year).slice(2)}`}
</span>
{COLS.map(({ name, m }) => {
const v = valueAt(y, m);
const i = y * 12 + m;
const on = same(hot, y, m);
const isEnd = span ? i === span.lo || i === span.hi : same(pinned, y, m);
// a year span lights whole rows; months in the picked years stay lit, the rest dim
const dim = span
? i < span.lo || i > span.hi
: yearSpan
? y < yearSpan.lo || y > yearSpan.hi
: !!hot && !on && hot.y !== y && hot.m !== m;
const lift = settled && on && canHover && !reduce ? 1.15 : 1;
return (
// the outer span owns the dim so it never fights the transforms inside
<span
key={`${year}-${name}`}
className="relative block aspect-square w-full transition-opacity duration-200"
style={{ opacity: dim ? 0.35 : 1 }}
>
{/* the hit area is the cell plus half the gap on every side, so the grid
has no dead space; the visual inside never takes pointer events */}
<motion.button
type="button"
data-return-cell={`${y}-${m}`}
aria-describedby={on ? tooltipId : undefined}
aria-label={`${MONTHS[m]} ${year} ${signed(v, 1)}%`}
aria-pressed={isEnd}
onPointerEnter={enter(y, m)}
onFocus={enter(y, m)}
onBlur={() => setHover(null)}
onClick={press(y, m)}
onKeyDown={clearOnEscape}
className="absolute -inset-0.5 block rounded-[5px] outline-none focus-visible:ring-2 focus-visible:ring-ring"
whileTap={reduce ? undefined : { scale: 0.92, transition: SPRING_PRESS }}
>
<motion.span
className="pointer-events-none absolute inset-0.5 grid place-items-center rounded-[4px] font-mono text-[9px] font-medium tabular-nums"
style={{
background: tint(v, range, on),
color: `color-mix(in srgb, var(--foreground) ${Math.round(85 + Math.min(Math.abs(v) / range, 1) * 15)}%, transparent)`,
boxShadow: isEnd ? RING : "none",
transition: "background 150ms, box-shadow 150ms",
}}
initial={reduce ? false : { opacity: 0, scale: 0.6 }}
// the diagonal wave on entrance; after it lands the hovered cell lifts,
// and hovering the year total replays the row Jan to Dec
animate={
sweepRow === y && !reduce
? {
opacity: 1,
// a gentle pulse: kept under the cell gap so a peaking cell
// never grows into its neighbours as the wave passes
scale: [1, 1.1, 1],
transition: { duration: 0.36, ease: EASE_OUT, delay: m * 0.035 },
}
: settled
? { opacity: 1, scale: lift, transition: SPRING_PRESS }
: {
opacity: 1,
scale: 1,
transition: reduce
? { duration: 0 }
: { ...SPRING_PRESS, delay: (y + m) * 0.02 },
}
}
>
{/* the ring lives INSIDE the cell: inset-0 fills its box and
borderRadius:inherit copies its corner, so it always shares the
cell's exact size, scale (hover lift included) and roundness and
cannot drift however the cells are restyled */}
<AnimatePresence>
{on && !isEnd ? (
<motion.span
className="pointer-events-none absolute inset-0 border-[1.5px]"
style={{ borderRadius: "inherit", borderColor: v >= 0 ? UP : DOWN }}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: EASE_OUT }}
/>
) : null}
</AnimatePresence>
{Math.abs(v) >= 4 ? Math.round(v) : ""}
</motion.span>
</motion.button>
</span>
);
})}
<span
className="relative block transition-opacity duration-200"
style={{
opacity: (
span
? y < Math.floor(span.lo / 12) || y > Math.floor(span.hi / 12)
: yearSpan
? y < yearSpan.lo || y > yearSpan.hi
: hot && hot.y !== y
)
? 0.35
: 1,
}}
>
<motion.button
type="button"
data-return-cell={`${y}-${YEAR}`}
aria-describedby={same(hot, y, YEAR) ? tooltipId : undefined}
aria-label={`${year} ${signed(totals[y], 1)}% for the year`}
aria-pressed={yearSpan ? y === yearSpan.lo || y === yearSpan.hi : same(pinned, y, YEAR)}
onPointerEnter={enter(y, YEAR)}
onFocus={enter(y, YEAR)}
onBlur={() => setHover(null)}
onClick={press(y, YEAR)}
onKeyDown={clearOnEscape}
className="absolute -inset-0.5 block rounded-[5px] outline-none focus-visible:ring-2 focus-visible:ring-ring"
whileTap={reduce ? undefined : { scale: 0.96, transition: SPRING_PRESS }}
>
<motion.span
className="pointer-events-none absolute inset-0.5 grid place-items-center rounded-[4px] font-mono text-[11px] font-medium tabular-nums"
// a neutral cell under the colored number: the tint of the same hue ate its contrast
style={{
background: same(hot, y, YEAR)
? "var(--background)"
: "color-mix(in srgb, var(--foreground) 6%, transparent)",
color: ink(totals[y] >= 0 ? UP : DOWN),
// the ends of a year span carry the locked ring, like a month span's ends
boxShadow: (yearSpan ? y === yearSpan.lo || y === yearSpan.hi : same(pinned, y, YEAR))
? RING
: same(hot, y, YEAR)
? "inset 0 0 0 1px var(--border-strong)"
: "none",
transition: "background 150ms, box-shadow 150ms",
}}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={
reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT, delay: 0.02 * (y + 13) }
}
>
{/* the year total gets the same flush hover ring as the month cells,
unless it already carries a span-end ring */}
<AnimatePresence>
{same(hot, y, YEAR) &&
!(yearSpan ? y === yearSpan.lo || y === yearSpan.hi : same(pinned, y, YEAR)) ? (
<motion.span
className="pointer-events-none absolute inset-0 border-[1.5px]"
style={{ borderRadius: "inherit", borderColor: totals[y] >= 0 ? UP : DOWN }}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: EASE_OUT }}
/>
) : null}
</AnimatePresence>
{signed(totals[y], 0)}%
</motion.span>
</motion.button>
</span>
</div>
))}
{children}
</div>
);
}
"use client";
import { type ReactNode, useMemo, useState } from "react";
import { NumberTicker } from "@/components/motion/number-ticker";
import { Tooltip } from "@/components/motion/tooltip";
import { cn } from "@/lib/utils";
import { useReturnsCalendar } from "./context";
import type { ReturnsCalendarCell, ReturnsCalendarTooltipData } from "./types";
import { DOWN, ink, UP } from "./utils";
export function ReturnsCalendarTooltip({
children,
className,
}: {
children?: ReactNode | ((data: ReturnsCalendarTooltipData) => ReactNode);
className?: string;
}) {
const { gridRef, tooltipId, tip, tipValue, tipLabel, tipNote } = useReturnsCalendar();
const [dismissed, setDismissed] = useState<ReturnsCalendarCell | null>(null);
const anchorRef = useMemo(
() => ({
get current() {
return tip
? (gridRef.current?.querySelector<HTMLElement>(`[data-return-cell="${tip.y}-${tip.m}"]`) ?? null)
: null;
},
}),
[gridRef, tip],
);
const data = { label: tipLabel, value: tipValue, note: tipNote };
return (
<Tooltip
key={tip ? `${tip.y}-${tip.m}` : "closed"}
open={tip !== null && dismissed !== tip}
onOpenChange={(open) => {
if (!open) setDismissed(tip);
}}
id={tooltipId}
anchorRef={anchorRef}
className={cn("flex items-center gap-1.5", className)}
content={
typeof children === "function"
? children(data)
: (children ?? (
<>
<span className="text-muted-foreground">{tipLabel}</span>
<span
className="inline-flex items-center font-mono tabular-nums"
style={{ color: ink(tipValue >= 0 ? UP : DOWN) }}
>
<NumberTicker
value={Math.round(Math.abs(tipValue) * 10)}
format={(v) => (v / 10).toFixed(1)}
prefix={tipValue >= 0 ? "+" : "−"}
suffix="%"
duration={0.35}
startOnView={false}
/>
</span>
{tipNote ? <span className="text-muted-foreground">{tipNote}</span> : null}
</>
))
}
/>
);
}
import type { ReactNode } from "react";
/** Zero-based row and month; month 12 selects the year total. */
export type ReturnsCalendarCell = { y: number; m: number };
export interface ReturnsCalendarSelection {
start: ReturnsCalendarCell;
end?: ReturnsCalendarCell;
}
export interface ReturnsCalendarProps {
/** Row labels, one per row of `returns`. */
years?: number[];
/** `returns[year][month]` in percent, twelve months per row. */
returns?: number[][];
className?: string;
children?: ReactNode;
selection?: ReturnsCalendarSelection | null;
defaultSelection?: ReturnsCalendarSelection | null;
onSelectionChange?: (selection: ReturnsCalendarSelection | null) => void;
}
export interface ReturnsCalendarTooltipData {
label: string;
value: number;
note: string | null;
}
import type { ReturnsCalendarCell } from "./types";
export const UP = "var(--success)";
export const DOWN = "var(--destructive)";
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
export const COLS = MONTHS.map((name, m) => ({ name, initial: name[0], m }));
/** The compounded column, addressed like a thirteenth month. */
export const YEAR = 12;
/** Compounded return of a run of percentages, in percent. */
export const compound = (run: number[]) => (run.reduce((acc, r) => acc * (1 + r / 100), 1) - 1) * 100;
export const signed = (v: number, dp: number) => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(dp)}`;
/** A hue as text: on the light page its lightness is capped so 11px numbers reach AA while the chroma stays, in dark it is native (`--ink-l` flips per theme on the root). */
export const ink = (c: string) => `oklch(from ${c} min(l, var(--ink-l, 1)) c h)`;
/** magnitude → strength of the one up or down hue, never a second color */
export const tint = (v: number, range: number, on: boolean) =>
`color-mix(in srgb, ${v >= 0 ? UP : DOWN} ${Math.round(Math.min(Math.abs(v) / range, 1) * 55 + (on ? 24 : 8))}%, transparent)`;
export const same = (a: ReturnsCalendarCell | null, y: number, m: number) => a?.y === y && a?.m === m;
export const RING = "inset 0 0 0 1.5px var(--foreground)";
"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>
);
}
API Reference
ReturnsCalendar
years?number[]Row labels, one per row of `returns`.
—returns?number[][]`returns[year][month]` in percent, twelve months per row.
—className?string—selection?ReturnsCalendarSelection | null—defaultSelection?ReturnsCalendarSelection | null—onSelectionChange?((selection: ReturnsCalendarSelection | null) => void)—ReturnsCalendarGrid
className?string—ReturnsCalendarTooltip
className?string—Related components
Heat Calendar
Composable activity calendar with Grid, Legend, and Tooltip parts, controlled range selection, and UTC calendar dates. Supply weekly intensities to visualize activity and total a selected span.
Price Target Fan
Composable price target chart with Header, Plot, SVG, Axes, History, Targets, Now, Cursor, and Tooltip parts. Supply dated price history and targets; scrub with a pointer or keyboard and customize the active readout.
Treemap
Proportional tiles that morph with your data, with value and share tooltips.
Contributed by
SavvaUpdated