Volume Profile
Animated volume by price, with point of control and value area highlights.
Preview
BTC / USD
Volume by price · Demo data
$66,400$66,400$65,800$65,800$65,200$65,200$64,600$64,600$64,000$64,000
POC
0139278
- Total volume
- 2,271 BTC
- Point of control
- $65,050
- Value area · 70% target
- $64,700 – $65,400
- 64.2% of volume
| Price range | Volume (BTC) | Share | Level |
|---|---|---|---|
| $64,000 to $64,100 | 18 | 0.8% | Outside value area |
| $64,100 to $64,200 | 27 | 1.2% | Outside value area |
| $64,200 to $64,300 | 36 | 1.6% | Outside value area |
| $64,300 to $64,400 | 52 | 2.3% | Outside value area |
| $64,400 to $64,500 | 70 | 3.1% | Outside value area |
| $64,500 to $64,600 | 96 | 4.2% | Outside value area |
| $64,600 to $64,700 | 135 | 5.9% | Outside value area |
| $64,700 to $64,800 | 172 | 7.6% | Inside value area |
| $64,800 to $64,900 | 205 | 9.0% | Inside value area |
| $64,900 to $65,000 | 241 | 10.6% | Inside value area |
| $65,000 to $65,100 | 278 | 12.2% | Point of control |
| $65,100 to $65,200 | 225 | 9.9% | Inside value area |
| $65,200 to $65,300 | 192 | 8.5% | Inside value area |
| $65,300 to $65,400 | 146 | 6.4% | Inside value area |
| $65,400 to $65,500 | 108 | 4.8% | Outside value area |
| $65,500 to $65,600 | 79 | 3.5% | Outside value area |
| $65,600 to $65,700 | 56 | 2.5% | Outside value area |
| $65,700 to $65,800 | 42 | 1.8% | Outside value area |
| $65,800 to $65,900 | 31 | 1.4% | Outside value area |
| $65,900 to $66,000 | 22 | 1.0% | Outside value area |
| $66,000 to $66,100 | 16 | 0.7% | Outside value area |
| $66,100 to $66,200 | 11 | 0.5% | Outside value area |
| $66,200 to $66,300 | 8 | 0.4% | Outside value area |
| $66,300 to $66,400 | 5 | 0.2% | Outside value area |
TSXcomponents/previews/charts/volume-profile.usage.tsx
"use client";
import {
VolumeProfile,
VolumeProfilePlot,
VolumeProfileSummary,
VolumeProfileTooltip,
type VolumeProfileBin,
} from "@/components/charts/volume-profile";
/** Aggregate your executed trades into non-overlapping price ranges upstream.
* Keep each range's ID stable to morph its bar when volume changes.
* valueArea is a target: whole bins can cover less, and the POC is always included. */
export function VolumeProfileExample({ data }: { data: readonly VolumeProfileBin[] }) {
return (
<VolumeProfile
data={data}
label="BTC volume profile"
unit="BTC"
valueArea={0.7}
formatPrice={(price) => `$${price.toLocaleString("en-US")}`}
formatVolume={(volume) => volume.toLocaleString("en-US", { maximumFractionDigits: 4 })}
>
<VolumeProfilePlot />
<VolumeProfileTooltip />
<VolumeProfileSummary />
</VolumeProfile>
);
}
TSXcomponents/charts/volume-profile.tsx
"use client";
// beui.dev/charts/volume-profile
import { cn } from "@/lib/utils";
import {
VolumeProfileContext,
useVolumeProfileModel,
type VolumeProfileProps,
} from "./volume-profile/context";
import { VolumeProfilePlot } from "./volume-profile/plot";
import { VolumeProfileSummary } from "./volume-profile/summary";
import { VolumeProfileTooltip } from "./volume-profile/tooltip";
/** Compose Plot, Tooltip and Summary, or omit children for the complete chart. */
export function VolumeProfile({
data,
label,
unit,
formatPrice,
formatVolume,
valueArea,
color,
pocColor,
activeId,
defaultActiveId,
onActiveChange,
className,
children,
...props
}: VolumeProfileProps) {
const model = useVolumeProfileModel({
data,
label,
unit,
formatPrice,
formatVolume,
valueArea,
color,
pocColor,
activeId,
defaultActiveId,
onActiveChange,
});
return (
<VolumeProfileContext.Provider value={model}>
<section
aria-label={model.label}
{...props}
data-slot="volume-profile"
className={cn("min-w-0 w-full space-y-5", className)}
>
{children === undefined ? (
<>
<VolumeProfilePlot />
<VolumeProfileTooltip />
<VolumeProfileSummary />
</>
) : (
children
)}
<div className="sr-only">
<table>
<caption>{model.label} data</caption>
<thead>
<tr>
<th scope="col">Price range</th>
<th scope="col">Volume ({model.unit})</th>
<th scope="col">Share</th>
<th scope="col">Level</th>
</tr>
</thead>
<tbody>
{model.rows.map((row) => (
<tr key={row.id}>
<th scope="row">
{model.formatPrice(row.priceLow)} to {model.formatPrice(row.priceHigh)}
</th>
<td>{model.formatVolume(row.volume)}</td>
<td>{(row.share * 100).toFixed(1)}%</td>
<td>
{row.isPoc
? "Point of control"
: row.inValueArea
? "Inside value area"
: model.valueArea
? "Outside value area"
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</VolumeProfileContext.Provider>
);
}
export { useVolumeProfile } from "./volume-profile/context";
export type { VolumeProfileProps } from "./volume-profile/context";
export type { VolumeProfileBin, VolumeProfileRow } from "./volume-profile/model";
export { VolumeProfilePlot } from "./volume-profile/plot";
export type { VolumeProfilePlotProps } from "./volume-profile/plot";
export { VolumeProfileSummary } from "./volume-profile/summary";
export { VolumeProfileTooltip } from "./volume-profile/tooltip";
export type { VolumeProfileTooltipProps } from "./volume-profile/tooltip";
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/volume-profilevolume-profilevolume-profile
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i @floating-ui/dom clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/hooks/use-dismiss.ts
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | SVGElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
TSXlib/hooks/use-hover-gesture.ts
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
TSXlib/hooks/use-tap-gesture.ts
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
TSXcomponents/charts/volume-profile.tsx
"use client";
// beui.dev/charts/volume-profile
import { cn } from "@/lib/utils";
import {
VolumeProfileContext,
useVolumeProfileModel,
type VolumeProfileProps,
} from "./volume-profile/context";
import { VolumeProfilePlot } from "./volume-profile/plot";
import { VolumeProfileSummary } from "./volume-profile/summary";
import { VolumeProfileTooltip } from "./volume-profile/tooltip";
/** Compose Plot, Tooltip and Summary, or omit children for the complete chart. */
export function VolumeProfile({
data,
label,
unit,
formatPrice,
formatVolume,
valueArea,
color,
pocColor,
activeId,
defaultActiveId,
onActiveChange,
className,
children,
...props
}: VolumeProfileProps) {
const model = useVolumeProfileModel({
data,
label,
unit,
formatPrice,
formatVolume,
valueArea,
color,
pocColor,
activeId,
defaultActiveId,
onActiveChange,
});
return (
<VolumeProfileContext.Provider value={model}>
<section
aria-label={model.label}
{...props}
data-slot="volume-profile"
className={cn("min-w-0 w-full space-y-5", className)}
>
{children === undefined ? (
<>
<VolumeProfilePlot />
<VolumeProfileTooltip />
<VolumeProfileSummary />
</>
) : (
children
)}
<div className="sr-only">
<table>
<caption>{model.label} data</caption>
<thead>
<tr>
<th scope="col">Price range</th>
<th scope="col">Volume ({model.unit})</th>
<th scope="col">Share</th>
<th scope="col">Level</th>
</tr>
</thead>
<tbody>
{model.rows.map((row) => (
<tr key={row.id}>
<th scope="row">
{model.formatPrice(row.priceLow)} to {model.formatPrice(row.priceHigh)}
</th>
<td>{model.formatVolume(row.volume)}</td>
<td>{(row.share * 100).toFixed(1)}%</td>
<td>
{row.isPoc
? "Point of control"
: row.inValueArea
? "Inside value area"
: model.valueArea
? "Outside value area"
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</VolumeProfileContext.Provider>
);
}
export { useVolumeProfile } from "./volume-profile/context";
export type { VolumeProfileProps } from "./volume-profile/context";
export type { VolumeProfileBin, VolumeProfileRow } from "./volume-profile/model";
export { VolumeProfilePlot } from "./volume-profile/plot";
export type { VolumeProfilePlotProps } from "./volume-profile/plot";
export { VolumeProfileSummary } from "./volume-profile/summary";
export { VolumeProfileTooltip } from "./volume-profile/tooltip";
export type { VolumeProfileTooltipProps } from "./volume-profile/tooltip";
TSXcomponents/charts/volume-profile/context.ts
"use client";
import { useReducedMotion } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { buildVolumeProfile, type VolumeProfileBin } from "./model";
const formatNumber = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 2 });
export interface VolumeProfileProps extends ComponentProps<"section"> {
/** Already-aggregated executed volume. Sorted by price; unequal bins and gaps are supported. */
data: readonly VolumeProfileBin[];
label?: string;
unit?: string;
formatPrice?: (price: number) => string;
formatVolume?: (volume: number) => string;
/** Target fraction, default 0.7. Whole bins may cover less; the POC is always included.
* Set false to hide the value area. Equal-volume POCs favor the lower price. */
valueArea?: number | false;
color?: string;
pocColor?: string;
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string | null) => void;
}
export function useVolumeProfileModel({
data,
label = "Volume profile",
unit = "units",
formatPrice = formatNumber,
formatVolume = formatNumber,
valueArea = 0.7,
color = "#6366f1",
pocColor = "#f59e0b",
activeId,
defaultActiveId = null,
onActiveChange,
}: VolumeProfileProps) {
const model = useMemo(() => buildVolumeProfile(data, valueArea), [data, valueArea]);
const [internalId, setInternalId] = useState(defaultActiveId);
if (internalId !== null && !model.rows.some((row) => row.id === internalId)) setInternalId(null);
const requestedId = activeId === undefined ? internalId : activeId;
const activeIndex = model.rows.findIndex((row) => row.id === requestedId);
const active = model.rows[activeIndex] ?? null;
const [tooltipOpen, setTooltipOpen] = useState(false);
const plotRef = useRef<HTMLDivElement>(null);
const tooltipId = useId();
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 describe = (row: (typeof model.rows)[number]) =>
[
`${formatPrice(row.priceLow)} to ${formatPrice(row.priceHigh)}`,
`${formatVolume(row.volume)} ${unit}`,
`${(row.share * 100).toFixed(1)}% of total volume`,
row.isPoc ? "Point of control" : row.inValueArea ? "Inside value area" : null,
]
.filter(Boolean)
.join(". ");
return {
...model,
label,
unit,
formatPrice,
formatVolume,
color,
pocColor,
describe,
active,
activeIndex,
setActive,
plotRef,
tooltipId,
tooltipOpen,
setTooltipOpen,
reduce: useReducedMotion(),
canHover: useHoverCapable(),
};
}
export const VolumeProfileContext = createContext<ReturnType<typeof useVolumeProfileModel> | null>(
null,
);
export function useVolumeProfile() {
const context = useContext(VolumeProfileContext);
if (!context) throw new Error("Volume profile parts must be rendered inside VolumeProfile.");
return context;
}
TSXcomponents/charts/volume-profile/model.ts
export interface VolumeProfileBin {
/** Stable identity for this price range across data updates. */
id: string;
priceLow: number;
priceHigh: number;
/** Executed volume in this range, in the consumer's chosen unit. */
volume: number;
}
export function buildVolumeProfile(data: readonly VolumeProfileBin[], target: number | false) {
if (target !== false && (!Number.isFinite(target) || target <= 0 || target > 1))
throw new Error("VolumeProfile: valueArea must be a fraction above 0 and at most 1, or false.");
const bins = [...data].sort((a, b) => a.priceLow - b.priceLow);
const ids = new Set<string>();
let totalVolume = 0;
let maxVolume = 0;
let pocIndex = -1;
for (const [index, bin] of bins.entries()) {
if (ids.has(bin.id)) throw new Error(`VolumeProfile: duplicate bin ID "${bin.id}".`);
ids.add(bin.id);
if (
!Number.isFinite(bin.priceLow) ||
!Number.isFinite(bin.priceHigh) ||
bin.priceHigh <= bin.priceLow ||
!Number.isFinite(bin.volume) ||
bin.volume < 0
)
throw new Error(
"VolumeProfile: bins need finite increasing price bounds and nonnegative volume.",
);
if (index > 0 && bin.priceLow < bins[index - 1].priceHigh)
throw new Error("VolumeProfile: price ranges must not overlap.");
totalVolume += bin.volume;
// Equal maxima keep the lower-price bin as a deterministic POC.
if (bin.volume > maxVolume) {
maxVolume = bin.volume;
pocIndex = index;
}
}
const priceLow = bins[0]?.priceLow ?? 0;
const priceHigh = bins.at(-1)?.priceHigh ?? 1;
const span = priceHigh - priceLow;
if (!Number.isFinite(totalVolume) || !Number.isFinite(span))
throw new Error("VolumeProfile: the total volume and price span must remain finite.");
let lowIndex = pocIndex;
let highIndex = pocIndex;
let areaVolume = pocIndex >= 0 ? bins[pocIndex].volume : 0;
if (target === 1 && pocIndex >= 0) {
// A 100% target includes every bin, including empty edge bins, without
// depending on floating-point summation order during adjacent expansion.
lowIndex = 0;
highIndex = bins.length - 1;
areaVolume = totalVolume;
} else if (target !== false && pocIndex >= 0) {
const targetVolume = totalVolume * target;
// Grow a contiguous area from the POC using the larger adjacent volume.
// Ties favor the nearer row, then the higher price. Stop before adding a
// row that would exceed the target; the POC itself is always included.
while (areaVolume < targetVolume) {
const below = bins[lowIndex - 1];
const above = bins[highIndex + 1];
if (!below && !above) break;
const takeAbove =
!below ||
(!!above &&
(above.volume > below.volume ||
(above.volume === below.volume &&
highIndex + 1 - pocIndex <= pocIndex - (lowIndex - 1))));
const next = takeAbove ? above : below;
if (!next || areaVolume + next.volume > targetVolume) break;
areaVolume += next.volume;
if (takeAbove) highIndex++;
else lowIndex--;
}
}
const valueArea =
target !== false && pocIndex >= 0
? {
priceLow: bins[lowIndex].priceLow,
priceHigh: bins[highIndex].priceHigh,
volume: areaVolume,
share: areaVolume / totalVolume,
target,
}
: null;
const rows = bins.map((bin, index) => ({
...bin,
price: bin.priceLow + (bin.priceHigh - bin.priceLow) / 2,
top: (priceHigh - bin.priceHigh) / span,
height: (bin.priceHigh - bin.priceLow) / span,
center: (priceHigh - (bin.priceLow + (bin.priceHigh - bin.priceLow) / 2)) / span,
proportion: maxVolume ? bin.volume / maxVolume : 0,
share: totalVolume ? bin.volume / totalVolume : 0,
isPoc: index === pocIndex,
inValueArea: valueArea !== null && index >= lowIndex && index <= highIndex,
}));
return {
rows,
poc: rows[pocIndex] ?? null,
valueArea,
totalVolume,
maxVolume,
priceLow,
priceHigh,
};
}
export type VolumeProfileRow = ReturnType<typeof buildVolumeProfile>["rows"][number];
TSXcomponents/charts/volume-profile/plot.tsx
"use client";
import { motion } from "motion/react";
import { useRef, type PointerEvent, type ReactNode } from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useVolumeProfile } from "./context";
export interface VolumeProfilePlotProps {
className?: string;
barClassName?: string;
/** Plot height is set by className; default 20rem. */
showAxes?: boolean;
children?: ReactNode;
}
export function VolumeProfilePlot({
className,
barClassName,
showAxes = true,
children,
}: VolumeProfilePlotProps) {
const {
rows,
label,
maxVolume,
priceLow,
priceHigh,
valueArea,
formatPrice,
formatVolume,
color,
pocColor,
activeIndex,
setActive,
plotRef,
tooltipId,
tooltipOpen,
setTooltipOpen,
describe,
reduce,
canHover,
} = useVolumeProfile();
const pointerDriven = useRef(false);
const focused = useRef(false);
const lastPointerId = useRef<string | null>(null);
const index = Math.max(activeIndex, 0);
const row = rows[index];
const inspect = (event: PointerEvent<HTMLInputElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
if (!bounds.height) return;
const fraction = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height));
// Hit-test real price geometry, including unequal bins and empty price gaps.
const match =
rows.find((item) => fraction >= item.top && fraction <= item.top + item.height) ??
rows.reduce((nearest, item) =>
Math.abs(item.center - fraction) < Math.abs(nearest.center - fraction) ? item : nearest,
);
setActive(match.id);
if (lastPointerId.current !== match.id) setTooltipOpen(true);
lastPointerId.current = match.id;
};
if (!rows.length)
return (
<p className={cn("py-12 text-center text-sm text-muted-foreground", className)}>
No volume data
</p>
);
return (
<div data-slot="volume-profile-plot" className={cn("min-w-0", showAxes && "pb-6")}>
<div className={cn("flex h-80 min-w-0 gap-3", className)}>
{showAxes ? (
<div
aria-hidden="true"
className="relative shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground sm:text-xs"
>
{[1, 0.75, 0.5, 0.25, 0].map((position) => (
<span key={position}>
<span className="invisible block">
{formatPrice(priceLow + (priceHigh - priceLow) * position)}
</span>
<span
className="absolute right-0 -translate-y-1/2"
style={{ top: `${(1 - position) * 100}%` }}
>
{formatPrice(priceLow + (priceHigh - priceLow) * position)}
</span>
</span>
))}
</div>
) : null}
<div
ref={plotRef}
className="relative min-w-0 flex-1 rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-4 has-focus-visible:outline-ring"
>
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{[0, 0.25, 0.5, 0.75, 1].map((position) => (
<span
key={position}
className="absolute inset-y-0 border-l border-border/60"
style={{ left: `${position * 100}%` }}
/>
))}
{rows.map((item, i) => (
<div
key={item.id}
data-volume-bin={item.id}
data-poc={item.isPoc || undefined}
data-value-area={item.inValueArea || undefined}
className="absolute inset-x-0 overflow-hidden py-px"
style={{ top: `${item.top * 100}%`, height: `${item.height * 100}%` }}
>
<motion.div
className={cn("h-full w-full rounded-r-sm", barClassName)}
style={{
backgroundColor: item.isPoc ? pocColor : color,
transformOrigin: "left",
}}
initial={{ opacity: 0, transform: `scaleX(${item.proportion})` }}
animate={{
opacity: item.isPoc || item.inValueArea || !valueArea ? 0.9 : 0.3,
transform: `scaleX(${item.proportion})`,
}}
transition={{
transform: reduce ? { duration: 0 } : SPRING_LAYOUT,
opacity: { duration: 0.18, ease: EASE_OUT },
}}
/>
<motion.div
className="absolute inset-0 bg-foreground/5"
initial={false}
animate={{ opacity: tooltipOpen && activeIndex === i ? 1 : 0 }}
transition={{ duration: 0.1, ease: EASE_OUT }}
/>
{item.isPoc ? (
<span
className="absolute inset-x-0 top-1/2 border-t border-dashed"
style={{ borderColor: pocColor }}
>
<span className="absolute right-0 -translate-y-1/2 rounded bg-background px-1.5 py-0.5 font-mono text-[10px] font-medium text-foreground">
POC
</span>
</span>
) : null}
</div>
))}
</div>
<input
type="range"
min={0}
max={rows.length - 1}
step={1}
value={index}
aria-label={`${label}: inspect price level`}
aria-orientation="vertical"
aria-valuetext={describe(row)}
aria-describedby={tooltipOpen && activeIndex >= 0 ? tooltipId : undefined}
className="absolute inset-0 m-0 h-full w-full cursor-crosshair opacity-0"
style={{ writingMode: "vertical-lr", direction: "rtl", touchAction: "pan-x" }}
onFocus={() => {
focused.current = true;
if (!pointerDriven.current) {
setActive(row.id);
setTooltipOpen(true);
}
}}
onBlur={() => {
focused.current = false;
pointerDriven.current = false;
setTooltipOpen(false);
}}
onPointerEnter={(event) => {
if (canHover && event.pointerType !== "touch") inspect(event);
}}
onPointerMove={(event) => {
if (
event.pointerType === "touch"
? event.buttons === 1
: canHover || event.buttons === 1
)
inspect(event);
}}
onPointerLeave={() => {
lastPointerId.current = null;
if (!focused.current || pointerDriven.current) setTooltipOpen(false);
}}
onPointerDown={(event) => {
pointerDriven.current = true;
lastPointerId.current = null;
inspect(event);
}}
onPointerCancel={() => {
pointerDriven.current = false;
setTooltipOpen(false);
}}
onClick={() => {
pointerDriven.current = false;
}}
onKeyDown={(event) => {
pointerDriven.current = false;
if (event.key === "Escape") setTooltipOpen(false);
else if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(
event.key,
)
) {
if (activeIndex < 0) setActive(row.id);
setTooltipOpen(true);
}
}}
onChange={(event) => {
if (pointerDriven.current) return;
const next = rows[Number(event.target.value)];
if (next) {
setActive(next.id);
setTooltipOpen(true);
}
}}
/>
{showAxes ? (
<div
aria-hidden="true"
className="absolute inset-x-0 top-full mt-2 flex justify-between font-mono text-[10px] tabular-nums text-muted-foreground sm:text-xs"
>
<span>0</span>
<span>{formatVolume(maxVolume / 2)}</span>
<span>{formatVolume(maxVolume)}</span>
</div>
) : null}
{children}
</div>
</div>
</div>
);
}
TSXcomponents/charts/volume-profile/summary.tsx
"use client";
import { cn } from "@/lib/utils";
import { useVolumeProfile } from "./context";
export function VolumeProfileSummary({ className }: { className?: string }) {
const { rows, totalVolume, poc, valueArea, formatPrice, formatVolume, unit, color, pocColor } =
useVolumeProfile();
if (!rows.length) return null;
return (
<dl
className={cn(
"grid grid-cols-2 gap-x-4 gap-y-4 border-t border-border pt-4 text-xs sm:grid-cols-3",
className,
)}
>
<div className="space-y-1.5">
<dt className="text-muted-foreground">Total volume</dt>
<dd className="font-mono font-medium tabular-nums">
{formatVolume(totalVolume)} {unit}
</dd>
</div>
<div className="space-y-1.5">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<span
aria-hidden="true"
className="size-1.5 rounded-full"
style={{ backgroundColor: pocColor }}
/>
Point of control
</dt>
<dd className="font-mono font-medium tabular-nums">{poc ? formatPrice(poc.price) : "—"}</dd>
</div>
{valueArea ? (
<div className="col-span-2 space-y-1.5 sm:col-span-1">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<span
aria-hidden="true"
className="size-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
Value area · {Math.round(valueArea.target * 100)}% target
</dt>
<dd className="font-mono font-medium tabular-nums">
{formatPrice(valueArea.priceLow)} – {formatPrice(valueArea.priceHigh)}
</dd>
<dd className="text-muted-foreground">{(valueArea.share * 100).toFixed(1)}% of volume</dd>
</div>
) : null}
</dl>
);
}
TSXcomponents/charts/volume-profile/tooltip.tsx
"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 { useVolumeProfile } from "./context";
import type { VolumeProfileRow } from "./model";
const formatShare = (share: number) => `${share.toFixed(1)}%`;
function TooltipNumber({ value, format }: { value: number; format: (value: number) => string }) {
return (
// Keep the short digit tween when composed inside spring-based Tabs.
<MotionConfig transition={{ type: "tween" }}>
<NumberTicker
value={value}
// NumberTicker rounds its formatter argument; use the original value to
// preserve fractional prices, volumes and shares in every readout.
format={() => format(value)}
startOnView={false}
duration={0.2}
stagger={0}
className="font-mono"
/>
</MotionConfig>
);
}
export interface VolumeProfileTooltipProps {
children?: ReactNode | ((row: VolumeProfileRow) => ReactNode);
className?: string;
side?: "top" | "right" | "bottom" | "left";
}
export function VolumeProfileTooltip({
children,
className,
side = "top",
}: VolumeProfileTooltipProps) {
const {
rows,
active,
plotRef,
tooltipId,
tooltipOpen,
setTooltipOpen,
formatPrice,
formatVolume,
unit,
} = useVolumeProfile();
// Mount the shared pointer tracker only once the plot's anchor exists,
// including when an initially empty profile receives its first data.
if (!rows.length) return null;
return (
<Tooltip
id={tooltipId}
anchorRef={plotRef}
followCursor
anchorPoint={{
x: active ? Math.max(0.1, active.proportion / 2) : 0.5,
y: active?.center ?? 0.5,
}}
open={tooltipOpen && active !== null}
onOpenChange={setTooltipOpen}
side={side}
className={cn("max-w-64", className)}
content={
active
? typeof children === "function"
? children(active)
: (children ?? (
<span className="flex flex-col gap-1.5 text-xs">
<span className="inline-flex items-center gap-1 font-medium tabular-nums">
<TooltipNumber value={active.priceLow} format={formatPrice} />
<span>–</span>
<TooltipNumber value={active.priceHigh} format={formatPrice} />
</span>
<span className="inline-flex flex-wrap items-center gap-1">
<TooltipNumber value={active.volume} format={formatVolume} /> {unit}
<span className="inline-flex items-center gap-1 text-muted-foreground">
<span>·</span>
<TooltipNumber value={active.share * 100} format={formatShare} />
</span>
</span>
{active.isPoc || active.inValueArea ? (
<span className="text-muted-foreground">
{active.isPoc ? "Point of control" : "Inside value area"}
</span>
) : null}
</span>
))
: null
}
/>
);
}
TSXcomponents/motion/number-ticker.tsx
"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;
/** 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,
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 rounded = Math.round(value);
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString()
: rounded.toString();
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, 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-center tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-center">
{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 (
<span
className={cn("relative inline-block overflow-hidden", className)}
style={{ height: `${DIGIT_HEIGHT_EM}em`, width: "1ch" }}
>
<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>
);
}
TSXcomponents/motion/tooltip.tsx
"use client";
import { AnimatePresence } from "motion/react";
import { TooltipPositioner } from "./tooltip/positioner";
import { useTooltipPointer } from "./tooltip/use-position";
import {
cloneElement,
isValidElement,
type PointerEvent,
type ReactElement,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { TooltipSurface } from "@/components/motion/tooltip-surface";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children?: ReactElement;
/** Existing trigger for controlled integrations such as chart cells. */
anchorRef?: RefObject<HTMLElement | SVGElement | null>;
/** Point within the anchor, as fractions of its rendered width and height. */
anchorPoint?: { x: number; y: number };
/** Follow real pointer coordinates; keyboard focus still uses the anchor. */
followCursor?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
id?: string;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
anchorRef: externalAnchorRef,
anchorPoint,
followCursor = false,
open: controlledOpen,
onOpenChange,
id: providedId,
}: TooltipProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (controlledOpen === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[controlledOpen, onOpenChange],
);
const generatedId = useId();
const id = providedId ?? generatedId;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapperRef = useRef<HTMLSpanElement>(null);
const anchorRef = externalAnchorRef ?? wrapperRef;
const hover = useHoverGesture();
const floatingRef = useRef<HTMLSpanElement | null>(null);
const pointer = useTooltipPointer(anchorRef, followCursor);
const focused = useRef(false);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
if (open) return;
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
if (warm) {
setOpen(true);
return;
}
timer.current = setTimeout(() => {
setOpen(true);
}, delay);
}, [delay, setOpen, open]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) lastHiddenAt = Date.now();
setOpen(false);
}, [open, setOpen]);
const leave = useCallback(() => {
if (focused.current) return;
if (timer.current) clearTimeout(timer.current);
// Bridge the small physical gap to a stationary, readable tooltip.
if (followCursor) hide();
else timer.current = setTimeout(hide, 100);
}, [followCursor, hide]);
const insideTooltip = useCallback(
(target: Element) => Boolean(floatingRef.current?.contains(target)),
[],
);
// A finger never hovers, and Safari does not focus a button on tap either, so
// the label is only reachable if the tap itself opens the tooltip. A click
// carries no pointerType, so the pointerdown that preceded it is what says
// whether this was a tap; keyboard activation arrives with no pointerdown at
// all, and focus has already shown the label there.
const tap = useTapGesture<boolean>();
const toggleOnTap = useCallback(() => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
if (gesture.state) {
hide();
return;
}
if (timer.current) clearTimeout(timer.current);
setOpen(true);
}, [hide, tap, setOpen]);
// ...and closed again by the next tap that lands somewhere else. The label
// covers nothing interactive, so that tap passes through to what it hit.
useDismiss(open, hide, anchorRef, { ignore: insideTooltip });
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
if (!externalAnchorRef && !isValidElement(children)) return children;
// The label describes the trigger, so it has to name the trigger itself.
// Everything else the tooltip needs is read off the anchor below instead of
// cloned on: a handler written onto the child is the child's handler as far
// as that child can tell, and a component that owns its activation —
// hard-wiring onClick and spreading the rest of its props over it, as
// ThemeToggle does — then runs the tooltip's instead of its own. Composing
// with `props.onClick` cannot save it either, because a component element's
// props hold nothing the component does internally.
const trigger = isValidElement(children)
? cloneElement(children as ReactElement<Record<string, unknown>>, {
"aria-describedby":
[(children.props as Record<string, unknown>)["aria-describedby"], open ? id : undefined]
.filter(Boolean)
.join(" ") || undefined,
})
: null;
return (
<>
{!externalAnchorRef ? (
// biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.
<span
ref={wrapperRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
// Pointer events, not the mouse pair: a tap fires compatibility
// mouseenter/mouseleave that carry no pointerType, which raced the tap
// path into opening and closing the same label.
onPointerEnter={(event: PointerEvent) => {
if (hover.enter(event)) show();
}}
onPointerLeave={(event: PointerEvent) => {
if (hover.leave(event)) leave();
}}
onFocus={() => {
focused.current = true;
show();
}}
onBlur={() => {
focused.current = false;
hide();
}}
onPointerDown={(event: PointerEvent) => tap.start(event, open)}
// A gesture the platform took away sends no click, and a key press
// starts an activation that never had a pointer behind it. Either way
// the record has to go, or the next click reads a finger that has long
// since lifted.
onPointerCancel={tap.drop}
onKeyDown={(event) => {
tap.drop();
if (event.key === "Escape") hide();
}}
onClick={toggleOnTap}
>
{trigger}
</span>
) : null}
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open ? (
<TooltipPositioner
key="tooltip"
anchorRef={anchorRef}
floatingRef={floatingRef}
anchorPoint={anchorPoint}
followCursor={followCursor}
side={side}
onDismiss={hide}
pointer={pointer}
>
{(positioned, isPresent) => (
<TooltipSurface
id={id}
ready={positioned}
side={side}
onPointerEnter={() => {
if (timer.current) clearTimeout(timer.current);
}}
onPointerLeave={leave}
style={{
maxWidth: "calc(100vw - 16px)",
whiteSpace: "normal",
pointerEvents: isPresent && !followCursor ? "auto" : "none",
}}
className={cn("overflow-hidden", className)}
>
{content}
</TooltipSurface>
)}
</TooltipPositioner>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
TSXcomponents/motion/tooltip/positioner.tsx
"use client";
import { useIsPresent } from "motion/react";
import { useCallback, useState, type ReactNode } from "react";
import { 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) => ReactNode;
}) {
const present = useIsPresent();
const [ready, setReady] = useState(false);
const onPosition = useCallback(() => setReady(true), []);
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)}
</span>
);
}
TSXcomponents/motion/tooltip/use-position.ts
"use client";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
type Placement,
type VirtualElement,
} from "@floating-ui/dom";
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
export type TooltipSide = "top" | "right" | "bottom" | "left";
export type TooltipPoint = { x: number; y: number };
/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */
export function useTooltipPosition({
open,
anchorRef,
floatingRef,
anchorPoint,
followCursor,
side,
onDismiss,
onPosition,
pointer,
}: {
open: boolean;
anchorRef: RefObject<HTMLElement | SVGElement | null>;
floatingRef: RefObject<HTMLSpanElement | null>;
anchorPoint?: TooltipPoint;
followCursor: boolean;
side: TooltipSide;
onDismiss: () => void;
onPosition: () => void;
pointer: ReturnType<typeof useTooltipPointer>;
}) {
const { cursor, onMove } = pointer;
const cursorSide = useRef<Placement | null>(null);
useLayoutEffect(() => {
cursorSide.current = open ? side : null;
}, [open, side]);
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 anchor = anchorRef.current;
const floating = floatingRef.current;
if (!open || !anchor || !floating) return;
const revision = ++version.current;
const currentCursor = followCursor ? cursor.current : null;
const reference: Element | VirtualElement =
currentCursor || pointX !== undefined || pointY !== undefined
? {
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;
void computePosition(reference, floating, {
strategy: "fixed",
placement: currentCursor ? (cursorSide.current ?? side) : side,
middleware: [offset(currentCursor ? 12 : 8), flip({ padding: 8 }), shift({ padding: 8 })],
}).then(({ x, y, placement }) => {
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 dpr = window.devicePixelRatio || 1;
floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;
const origin = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
floating.style.setProperty(
"--tooltip-origin",
origin[placement.split("-")[0] as TooltipSide],
);
floating.style.visibility = "visible";
floating.dataset.placement = placement;
onPosition();
});
};
if (open) schedule();
}, [
open,
anchorRef,
floatingRef,
followCursor,
pointX,
pointY,
side,
schedule,
onPosition,
cursor,
]);
useLayoutEffect(() => {
const anchor = anchorRef.current;
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, anchorRef, floatingRef, followCursor, onDismiss, schedule, cursor]);
useLayoutEffect(
() => () => {
version.current++;
if (frame.current !== null) cancelAnimationFrame(frame.current);
},
[],
);
}
/** Pointer lifetime belongs to the trigger, including the opening delay. */
export function useTooltipPointer(
anchorRef: RefObject<HTMLElement | SVGElement | null>,
followCursor: boolean,
) {
const cursor = useRef<TooltipPoint | null>(null);
const onMove = useRef<(() => void) | null>(null);
const pointerFocus = useRef(false);
useLayoutEffect(() => {
const anchor = anchorRef.current;
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);
};
}, [anchorRef, followCursor]);
return { cursor, onMove };
}
TSXcomponents/motion/tooltip-surface.tsx
"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>
);
}
TSXcomponents/motion/tabs.tsx
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cancelFrame, frame, motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useLayoutEffect,
useRef,
useMemo,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
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",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
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 contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
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,
...props
}: {
wrapperClassName?: string;
} & HTMLAttributes<HTMLDivElement>) {
const { variant, value } = useTabs();
const reduce = useReducedMotion();
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(() => {
if (variant === "underline") return;
const list = listRef.current;
if (!list) return;
void children;
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);
return () => cancelFrame(syncClips);
}, [value, children, variant, reduce]);
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" className={cn(listClasses[variant], "w-max", 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>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
onClick,
...props
}: {
value: string;
indicatorClassName?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value">) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
// 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
{...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,
)}
>
{children}
{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";
return (
<div className="relative shrink-0">
{active ? (
<motion.span
data-tabs-indicator=""
layoutId={layoutId}
layout
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
{...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,
)}
>
{children}
<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 }}
>
{children}
</span>
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// 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 hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
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", className)}
>
{children}
</motion.div>
);
}
API Reference
VolumeProfile
datareadonly VolumeProfileBin[]Already-aggregated executed volume. Sorted by price; unequal bins and gaps are supported.
—label?string—unit?string—formatPrice?((price: number) => string)—formatVolume?((volume: number) => string)—valueArea?number | falseTarget fraction, default 0.7. Whole bins may cover less; the POC is always included. Set false to hide the value area. Equal-volume POCs favor the lower price.
—color?string—pocColor?string—activeId?string | null—defaultActiveId?string | null—onActiveChange?((id: string | null) => void)—className?string—VolumeProfilePlot
className?string—barClassName?string—showAxes?booleanPlot height is set by className; default 20rem.
trueVolumeProfileSummary
className?string—VolumeProfileTooltip
className?string—side?"bottom" | "left" | "right" | "top"topRelated components
Updated