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.
Preview
$205.00+14.8%Mean target
TSXcomponents/previews/charts/price-target-fan.preview.tsx
"use client";
import {
PriceTargetFan,
PriceTargetFanHeader,
PriceTargetFanPlot,
PriceTargetFanSvg,
PriceTargetFanAxes,
PriceTargetFanHistory,
PriceTargetFanTargets,
PriceTargetFanNow,
PriceTargetFanCursor,
PriceTargetFanTooltip,
} from "@/components/charts/price-target-fan";
// Illustrative prices. Replace these dated samples with your own API response.
const history = [
{ date: "2025-09-14", price: 158.4 },
{ date: "2025-10-14", price: 164.2 },
{ date: "2025-11-14", price: 160.8 },
{ date: "2025-12-14", price: 173.1 },
{ date: "2026-01-14", price: 170.6 },
{ date: "2026-02-14", price: 181.3 },
{ date: "2026-03-14", price: 175.8 },
{ date: "2026-04-14", price: 183.7 },
{ date: "2026-05-14", price: 178.2 },
{ date: "2026-06-14", price: 184.1 },
{ date: "2026-07-14", price: 180.5 },
{ date: "2026-08-14", price: 177.3 },
{ date: "2026-09-14", price: 178.52 },
];
export function PriceTargetFanPreview() {
return (
<PriceTargetFan
current={178.52}
history={history}
targets={[
{ key: "High", price: 232, analysts: 9 },
{ key: "Mean", price: 205, analysts: 34 },
{ key: "Low", price: 168, analysts: 6 },
]}
dates={{ horizon: "Sep 2027" }}
>
<PriceTargetFanHeader />
<PriceTargetFanPlot>
<PriceTargetFanSvg>
<PriceTargetFanAxes />
<PriceTargetFanHistory />
<PriceTargetFanTargets />
<PriceTargetFanNow />
<PriceTargetFanCursor />
</PriceTargetFanSvg>
<PriceTargetFanTooltip />
</PriceTargetFanPlot>
</PriceTargetFan>
);
}
TSXcomponents/charts/price-target-fan.tsx
"use client";
// beui.dev/charts/price-target-fan
import { cn } from "@/lib/utils";
import { PriceTargetFanContext, usePriceTargetFanModel } from "./price-target-fan/context";
import { PriceTargetFanHeader } from "./price-target-fan/header";
import { PriceTargetFanPlot } from "./price-target-fan/plot";
import type { PriceTargetFanProps } from "./price-target-fan/types";
/** Owns chart data and interaction; children may replace or rearrange the default parts. */
export function PriceTargetFan({ children, className, ...props }: PriceTargetFanProps) {
const model = usePriceTargetFanModel(props);
return (
<PriceTargetFanContext.Provider value={model}>
<div className={cn("w-[520px] max-w-full [--ink-l:0.5] dark:[--ink-l:1]", className)}>
{children === undefined ? (
<>
<PriceTargetFanHeader />
<PriceTargetFanPlot />
</>
) : (
children
)}
</div>
</PriceTargetFanContext.Provider>
);
}
export { usePriceTargetFan } from "./price-target-fan/context";
export { PriceTargetFanHeader } from "./price-target-fan/header";
export { PriceTargetFanAxes, PriceTargetFanPlot, PriceTargetFanSvg } from "./price-target-fan/plot";
export {
PriceTargetFanCursor,
PriceTargetFanHistory,
PriceTargetFanNow,
PriceTargetFanTargets,
} from "./price-target-fan/series";
export { PriceTargetFanTooltip } from "./price-target-fan/tooltip";
export type {
PriceHistoryPoint,
PriceTarget,
PriceTargetFanActive,
PriceTargetFanProps,
} from "./price-target-fan/types";
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/price-target-fan
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx 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/price-target-fan.tsx
"use client";
// beui.dev/charts/price-target-fan
import { cn } from "@/lib/utils";
import { PriceTargetFanContext, usePriceTargetFanModel } from "./price-target-fan/context";
import { PriceTargetFanHeader } from "./price-target-fan/header";
import { PriceTargetFanPlot } from "./price-target-fan/plot";
import type { PriceTargetFanProps } from "./price-target-fan/types";
/** Owns chart data and interaction; children may replace or rearrange the default parts. */
export function PriceTargetFan({ children, className, ...props }: PriceTargetFanProps) {
const model = usePriceTargetFanModel(props);
return (
<PriceTargetFanContext.Provider value={model}>
<div className={cn("w-[520px] max-w-full [--ink-l:0.5] dark:[--ink-l:1]", className)}>
{children === undefined ? (
<>
<PriceTargetFanHeader />
<PriceTargetFanPlot />
</>
) : (
children
)}
</div>
</PriceTargetFanContext.Provider>
);
}
export { usePriceTargetFan } from "./price-target-fan/context";
export { PriceTargetFanHeader } from "./price-target-fan/header";
export { PriceTargetFanAxes, PriceTargetFanPlot, PriceTargetFanSvg } from "./price-target-fan/plot";
export {
PriceTargetFanCursor,
PriceTargetFanHistory,
PriceTargetFanNow,
PriceTargetFanTargets,
} from "./price-target-fan/series";
export { PriceTargetFanTooltip } from "./price-target-fan/tooltip";
export type {
PriceHistoryPoint,
PriceTarget,
PriceTargetFanActive,
PriceTargetFanProps,
} from "./price-target-fan/types";
TSXcomponents/charts/price-target-fan/context.ts
"use client";
import { useReducedMotion } from "motion/react";
import { createContext, useCallback, useContext, useId, useMemo, useRef, useState } from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import type { PriceTargetFanActive, PriceTargetFanProps } from "./types";
import { EMPTY_HISTORY, fmtScrubDate, H, LOW, MID, PAD, UP, W } from "./utils";
/**
* Analyst price targets. A year of history draws itself to now, then three
* dashed projections fan out to the high, mean and low targets. The hot target
* owns the header: its price rolls in on the shared NumberTicker and its
* projection draws itself solid; the mean takes the header back on leave.
* Scrubbing the history or hovering a target glides a value card
* beside the point on a spring, one metric per row. Targets are focusable and
* a tap toggles them on touch; Escape lets go. The now dot sends a slow ring
* outward as the live-price signal. Reduced motion shows the finished chart,
* moves the card without travel, and drops the ring.
*/
export function usePriceTargetFanModel({
label = "Price target · 12 months",
current,
targets,
dates = {},
history = EMPTY_HISTORY,
active: controlledActive,
defaultActive = null,
onActiveChange,
}: PriceTargetFanProps) {
const reduce = useReducedMotion();
const clipId = useId();
const fadeId = `${clipId}-fade`;
const svgRef = useRef<SVGSVGElement>(null);
const canHover = useHoverCapable();
const tooltipId = useId();
const [internalActive, setInternalActive] = useState(defaultActive);
const requestedActive = controlledActive === undefined ? internalActive : controlledActive;
const setActive = (next: PriceTargetFanActive | null) => {
if (controlledActive === undefined) setInternalActive(next);
onActiveChange?.(next);
};
const targetIndex =
requestedActive?.type === "target"
? targets.findIndex((target) => target.key === requestedActive.key)
: -1;
const historyIndex =
requestedActive?.type === "history"
? history.findIndex((point) => point.date === requestedActive.date)
: -1;
const active = targetIndex >= 0 || historyIndex >= 0 ? requestedActive : null;
if (requestedActive && !active && controlledActive === undefined) setInternalActive(null);
const hotT = targetIndex >= 0 ? targetIndex : null;
const scrub = historyIndex >= 0 ? historyIndex : null;
const setHotT = (index: number | null) =>
setActive(index === null ? null : { type: "target", key: targets[index].key });
const setScrub = (index: number | null) =>
setActive(index === null ? null : { type: "history", date: history[index].date });
const hist = useMemo(() => {
let previous = Number.NEGATIVE_INFINITY;
return history.map((point) => {
const time = Date.parse(point.date);
if (
!/^\d{4}-\d{2}-\d{2}(?:T.*(?:Z|[+-]\d{2}:\d{2}))?$/.test(point.date) ||
!Number.isFinite(time) ||
time <= previous ||
!Number.isFinite(point.price) ||
point.price <= 0
) {
throw new RangeError(
"PriceTargetFan history must contain positive prices and ascending, unique ISO dates",
);
}
previous = time;
return point.price;
});
}, [history]);
if (
!Number.isFinite(current) ||
current <= 0 ||
targets.length !== 3 ||
targets.some((target) => !Number.isFinite(target.price) || target.price <= 0) ||
new Set(targets.map((target) => target.key)).size !== 3
) {
throw new RangeError(
"PriceTargetFan requires a positive current price and three positive targets with unique keys",
);
}
// the domain follows the data: whatever history and targets arrive, the
// chart fills its height instead of assuming a $150-250 stock
const yLo = Math.min(current, ...hist, ...targets.map((t) => t.price));
const yHi = Math.max(current, ...hist, ...targets.map((t) => t.price));
const yPad = Math.max((yHi - yLo) * 0.06, 0.5);
const yMin = yLo - yPad;
const yMax = yHi + yPad;
const y = useCallback(
(v: number) => PAD.t + (1 - (v - yMin) / (yMax - yMin)) * (H - PAD.t - PAD.b),
[yMin, yMax],
);
const pct = (p: number) => ((p - current) / current) * 100;
const fmt = (p: number) => `$${p.toFixed(2)}`;
const geo = useMemo(() => {
const histW = (W - PAD.l - PAD.r) * 0.56;
const nowX = PAD.l + histW;
const times = history.map((point) => Date.parse(point.date));
const first = times[0] ?? 0;
const last = times[times.length - 1] ?? first;
const hx = (i: number) =>
last === first ? nowX - 12 : PAD.l + ((times[i] - first) / (last - first)) * (histW - 12);
const nowY = y(current);
const endX = W - PAD.r;
const line =
hist.map((v, i) => `${i === 0 ? "M" : "L"}${hx(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ") +
(hist.length ? ` L${nowX},${nowY}` : "");
const proj = targets.map((t, i) => {
const ty = y(t.price);
const cx = nowX + (endX - nowX) * 0.5;
const cy = nowY + (ty - nowY) * 0.15;
const color = t.color ?? [UP, MID, LOW][i];
return { ...t, color, ty, d: `M${nowX},${nowY} Q${cx},${cy} ${endX},${ty}`, cx, cy };
});
return { hx, nowX, nowY, endX, line, proj };
}, [hist, history, targets, current, y]);
const onMove = (e: React.PointerEvent) => {
if (!canHover || e.pointerType === "touch") return;
const el = svgRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = ((e.clientX - r.left) / r.width) * W;
if (px > geo.nowX + 6) {
// in the fan the nearest projection owns the pointer, so the card walks
// from high to mean to low as the pointer drifts instead of dropping out
const py = ((e.clientY - r.top) / r.height) * H;
const t = Math.min(1, (px - geo.nowX) / (geo.endX - geo.nowX));
let nearest = 0;
let best = Number.POSITIVE_INFINITY;
const ys = geo.proj.map((p) => (1 - t) * (1 - t) * geo.nowY + 2 * (1 - t) * t * p.cy + t * t * p.ty);
ys.forEach((cy, i) => {
const d = Math.abs(py - cy);
if (d < best) {
best = d;
nearest = i;
}
});
// right after now the three curves still overlap; picking one there would be a guess
const spread = Math.max(...ys) - Math.min(...ys);
setHotT(spread < 24 ? null : nearest);
return;
}
if (!hist.length || px > geo.nowX - 6) {
setActive(null);
return;
}
let nearest = 0;
for (let i = 1; i < hist.length; i++) {
if (Math.abs(geo.hx(i) - px) < Math.abs(geo.hx(nearest) - px)) nearest = i;
}
setScrub(nearest);
};
// a hovered target wins over the scrub; the card sits on whichever side keeps it in view
const overlay = (() => {
if (hotT !== null) {
const p = geo.proj[hotT];
const up = p.price >= current;
return {
px: geo.endX,
py: p.ty,
// the horizon rides in the faint header so the target has a date without a row
title: `${p.key} target${dates.horizon ? ` · ${dates.horizon}` : ""}`,
// one metric per row, label against value; the move sits alone under a hairline
metrics: [
{ label: "Price", value: fmt(p.price) },
{ label: "Analysts", value: String(p.analysts) },
],
accent: {
label: "vs now",
value: `${up ? "+" : "−"}${Math.abs(pct(p.price)).toFixed(1)}%`,
color: p.color as string,
} as { label: string; value: string; color: string } | null,
};
}
if (scrub !== null) {
const title = fmtScrubDate.format(new Date(history[scrub].date));
return {
px: geo.hx(scrub),
py: y(hist[scrub]),
title,
metrics: [{ label: "Price", value: fmt(hist[scrub]) }],
accent: null as { label: string; value: string; color: string } | null,
};
}
return null;
})();
const mean = targets[1];
// whichever target is hot owns the header; the mean holds it otherwise
const head = hotT !== null ? geo.proj[hotT] : { key: mean.key, price: mean.price, color: undefined };
const headPct = pct(head.price);
// round-numbered gridlines derived from the domain, at most four of them
const gridVals = useMemo(() => {
const span = yMax - yMin;
const mag = 10 ** Math.floor(Math.log10(span / 3.2));
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= 4.2) ?? 10 * mag;
const out: number[] = [];
for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) out.push(v);
return { step, out };
}, [yMin, yMax]);
const drawn = reduce ? { duration: 0 } : undefined;
return {
reduce,
clipId,
fadeId,
svgRef,
tooltipId,
active,
setActive,
scrub,
hotT,
setHotT,
setScrub,
hist,
history,
current,
targets,
dates,
label,
y,
pct,
fmt,
geo,
overlay,
mean,
head,
headPct,
gridVals,
drawn,
onMove,
};
}
export const PriceTargetFanContext = createContext<ReturnType<typeof usePriceTargetFanModel> | null>(null);
export function usePriceTargetFan() {
const context = useContext(PriceTargetFanContext);
if (!context) throw new Error("PriceTargetFan parts must be inside PriceTargetFan");
return context;
}
TSXcomponents/charts/price-target-fan/header.tsx
"use client";
import type { ReactNode } from "react";
import { NumberTicker } from "@/components/motion/number-ticker";
import { cn } from "@/lib/utils";
import { usePriceTargetFan } from "./context";
import { ink, LOW, UP } from "./utils";
export function PriceTargetFanHeader({ children, className }: { children?: ReactNode; className?: string }) {
const { head, headPct } = usePriceTargetFan();
return (
<div className={cn("mb-1 flex items-end justify-between px-1", className)}>
{children ?? (
<div className="flex flex-wrap items-center gap-2">
<NumberTicker
value={Math.round(head.price * 100)}
format={(v) => (v / 100).toFixed(2)}
prefix="$"
duration={0.5}
className="font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground"
/>
<span
className="font-mono text-xs font-medium tabular-nums"
style={{ color: ink((head.color as string | undefined) ?? (headPct >= 0 ? UP : LOW)) }}
>
<NumberTicker
value={Math.round(Math.abs(headPct) * 10)}
format={(v) => (v / 10).toFixed(1)}
prefix={headPct >= 0 ? "+" : "−"}
suffix="%"
duration={0.5}
/>
</span>
<span className="text-xs text-muted-foreground">{head.key} target</span>
</div>
)}
</div>
);
}
TSXcomponents/charts/price-target-fan/plot.tsx
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { usePriceTargetFan } from "./context";
import {
PriceTargetFanCursor,
PriceTargetFanHistory,
PriceTargetFanNow,
PriceTargetFanTargets,
} from "./series";
import { PriceTargetFanTooltip } from "./tooltip";
import { fmtAxisDate, H, PAD, W } from "./utils";
/** HTML positioning container. Place Tooltip beside SVG, never inside SVG. */
export function PriceTargetFanPlot({ children, className }: { children?: ReactNode; className?: string }) {
return (
<div className={cn("relative", className)}>
{children === undefined ? (
<>
<PriceTargetFanSvg />
<PriceTargetFanTooltip />
</>
) : (
children
)}
</div>
);
}
export function PriceTargetFanSvg({ children, className }: { children?: ReactNode; className?: string }) {
const { svgRef, label, mean, fmt, current, onMove, setActive, fadeId, clipId, reduce, geo, drawn } =
usePriceTargetFan();
return (
// biome-ignore lint/a11y/useSemanticElements: SVG cannot contain a fieldset; group exposes the interactive SVG descendants.
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className={cn("block h-auto w-full cursor-crosshair [&_*]:cursor-crosshair", className)}
role="group"
aria-label={`${label}: mean ${fmt(mean.price)}, now ${fmt(current)}`}
onPointerMove={onMove}
onPointerLeave={(event) => {
if (event.pointerType !== "touch") setActive(null);
}}
>
<defs>
{/* both vertical guides fade out toward the top so they read as markers, not walls */}
<linearGradient id={fadeId} gradientUnits="userSpaceOnUse" x1={0} y1={PAD.t} x2={0} y2={H - PAD.b}>
<stop offset="0" stopColor="var(--border-strong)" stopOpacity={0} />
<stop offset="0.45" stopColor="var(--border-strong)" stopOpacity={1} />
<stop offset="1" stopColor="var(--border-strong)" stopOpacity={1} />
</linearGradient>
{/* the fan reveals left to right through this clip, so the dashed projections draw as lines */}
<clipPath id={clipId}>
<motion.rect
x={geo.nowX}
y={0}
height={H}
initial={{ width: reduce ? W - geo.nowX : 0 }}
animate={{ width: W - geo.nowX }}
transition={drawn ?? { duration: 0.8, ease: EASE_OUT, delay: 0.85 }}
/>
</clipPath>
</defs>
{children === undefined ? (
<>
<PriceTargetFanAxes />
<PriceTargetFanHistory />
<PriceTargetFanTargets />
<PriceTargetFanNow />
<PriceTargetFanCursor />
</>
) : (
children
)}
</svg>
);
}
export function PriceTargetFanAxes({ className }: { className?: string }) {
const { gridVals, y, geo, dates, history, fadeId } = usePriceTargetFan();
return (
<g className={cn(className)}>
{/* gridlines and the price axis */}
{gridVals.out.map((v) => (
<g key={v}>
<line x1={PAD.l} y1={y(v)} x2={geo.endX} y2={y(v)} stroke="var(--border)" strokeDasharray="2 5" />
<text
x={PAD.l - 8}
y={y(v) + 3}
textAnchor="end"
fontSize={9}
fill="var(--muted-foreground)"
className="font-mono tabular-nums"
>
{gridVals.step >= 1 ? Math.round(v) : v.toFixed(1)}
</text>
</g>
))}
{/* the date axis: history, now, horizon */}
{[
...(history.length
? [{ x: geo.hx(0), t: dates.start ?? fmtAxisDate(history[0].date), a: "start" as const }]
: []),
...(history.length > 2
? [
{
x: geo.hx(Math.floor(history.length / 2)),
t: dates.mid ?? fmtAxisDate(history[Math.floor(history.length / 2)].date),
a: "middle" as const,
},
]
: []),
{ x: geo.nowX, t: "Now", a: "middle" as const },
{ x: geo.endX, t: dates.horizon ?? "Target", a: "middle" as const },
].map((d) => (
<text
key={d.x}
x={d.x}
y={H - 8}
textAnchor={d.a}
fontSize={9}
fill="var(--muted-foreground)"
className="font-mono"
>
{d.t}
</text>
))}
{/* the now marker */}
<line
x1={geo.nowX}
y1={PAD.t}
x2={geo.nowX}
y2={H - PAD.b}
stroke={`url(#${fadeId})`}
strokeWidth={1}
strokeDasharray="3 3"
/>
</g>
);
}
TSXcomponents/charts/price-target-fan/series.tsx
"use client";
import { motion } from "motion/react";
import { EASE_OUT, SPRING_GLIDE, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { usePriceTargetFan } from "./context";
import { H, ink, PAD } from "./utils";
export function PriceTargetFanHistory({ className }: { className?: string }) {
const { hist, history, scrub, setScrub, setActive, tooltipId, fmt, geo, reduce, drawn } =
usePriceTargetFan();
return (
<g className={cn(className)}>
{hist.length ? (
<g
role="slider"
tabIndex={0}
aria-label="Price history"
aria-valuemin={0}
aria-valuemax={hist.length - 1}
aria-valuenow={scrub ?? hist.length - 1}
aria-valuetext={`${history[scrub ?? hist.length - 1].date}: ${fmt(hist[scrub ?? hist.length - 1])}`}
aria-describedby={scrub !== null ? tooltipId : undefined}
onFocus={() => setScrub(hist.length - 1)}
onBlur={() => setActive(null)}
onKeyDown={(event) => {
const index = scrub ?? hist.length - 1;
if (event.key === "Escape") {
event.preventDefault();
setActive(null);
} else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
setScrub(
event.key === "Home"
? 0
: event.key === "End"
? hist.length - 1
: Math.max(0, Math.min(hist.length - 1, index + (event.key === "ArrowRight" ? 1 : -1))),
);
}
}}
>
{" "}
{/* history draws itself to now */}
<motion.path
data-price-history=""
d={geo.line}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.5}
strokeLinecap="round"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={drawn ?? { duration: 0.9, ease: EASE_OUT }}
/>
</g>
) : null}
</g>
);
}
export function PriceTargetFanTargets({ className }: { className?: string }) {
const { geo, hotT, setHotT, setActive, tooltipId, clipId, reduce, drawn, fmt } = usePriceTargetFan();
return (
<g className={cn(className)}>
{/* projections and target labels */}
<g clipPath={`url(#${clipId})`}>
{geo.proj.map((p, i) => {
const on = hotT === i;
const dim = hotT !== null && !on;
return (
<motion.g
key={p.key}
role="button"
tabIndex={0}
aria-label={`${p.key} target ${fmt(p.price)}, ${p.analysts} analysts`}
className="outline-none"
aria-pressed={on}
aria-describedby={on ? tooltipId : undefined}
initial={{ opacity: 1 }}
animate={{ opacity: dim ? 0.3 : 1 }}
transition={drawn ?? { duration: 0.25, ease: EASE_OUT }}
onFocus={() => setHotT(i)}
onBlur={() => setHotT(null)}
// a tap toggles on touch, where hover never fires; mouse and pen keep hover
onPointerDown={(e) => {
if (e.pointerType === "touch") {
e.preventDefault();
setHotT(on ? null : i);
}
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setActive(null);
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setHotT(on ? null : i);
}
}}
>
<path d={p.d} fill="none" stroke="transparent" strokeWidth={16} />
<path
d={p.d}
fill="none"
stroke={p.color}
strokeWidth={on ? 2.2 : 1.4}
strokeOpacity={on ? 1 : 0.75}
strokeDasharray="2 4"
strokeLinecap="round"
/>
{/* the hot projection draws itself solid over the dashes, base to target */}
{on && (
<motion.path
d={p.d}
fill="none"
stroke={p.color}
strokeWidth={2.2}
strokeLinecap="round"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={drawn ?? { duration: 0.35, ease: EASE_OUT }}
/>
)}
<motion.circle
cx={geo.endX}
cy={p.ty}
fill="var(--background)"
stroke={p.color}
strokeWidth={1.6}
r={3.2}
animate={{ r: on ? 4.5 : 3.2 }}
transition={drawn ?? SPRING_PANEL}
/>
<text
x={geo.endX + 10}
y={p.ty + 4}
fontSize={11}
fontWeight={600}
fill={ink(p.color)}
className="font-mono tabular-nums"
>
{p.price}
</text>
</motion.g>
);
})}
</g>
</g>
);
}
export function PriceTargetFanNow({ className }: { className?: string }) {
const { geo, reduce, drawn } = usePriceTargetFan();
return (
<g className={cn(className)}>
{/* the now dot lands as the history arrives */}
<motion.circle
cx={geo.nowX}
cy={geo.nowY}
r={3.2}
fill="var(--foreground)"
initial={{ opacity: reduce ? 1 : 0, scale: reduce ? 1 : 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={drawn ?? { ...SPRING_PANEL, delay: 0.85 }}
style={{ transformOrigin: `${geo.nowX}px ${geo.nowY}px` }}
/>
{/* the live tail: a ring leaves the now dot every few seconds */}
{!reduce && (
<motion.circle
cx={geo.nowX}
cy={geo.nowY}
r={3.2}
fill="none"
stroke="var(--foreground)"
strokeWidth={1}
initial={{ opacity: 0, scale: 1 }}
animate={{ opacity: [0.45, 0], scale: [1, 2.8] }}
transition={{
duration: 2.2,
ease: EASE_OUT,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 1.6,
delay: 1.6,
}}
style={{ transformOrigin: `${geo.nowX}px ${geo.nowY}px` }}
/>
)}
</g>
);
}
export function PriceTargetFanCursor({ className }: { className?: string }) {
const { scrub, geo, y, hist, fadeId, reduce } = usePriceTargetFan();
return (
<g className={cn(className)}>
{/* the scrub crosshair: it flows along the history on a spring instead of
stepping cell to cell, so a slow drag reads as one continuous read-out */}
{scrub !== null && (
<g pointerEvents="none">
<motion.line
y1={PAD.t}
y2={H - PAD.b}
stroke={`url(#${fadeId})`}
strokeWidth={1}
initial={false}
animate={{ x1: geo.hx(scrub), x2: geo.hx(scrub) }}
transition={reduce ? { duration: 0 } : { type: "spring", ...SPRING_GLIDE }}
/>
<motion.circle
r={3.2}
fill="var(--foreground)"
stroke="var(--background)"
strokeWidth={1.5}
initial={false}
animate={{ cx: geo.hx(scrub), cy: y(hist[scrub]) }}
transition={reduce ? { duration: 0 } : { type: "spring", ...SPRING_GLIDE }}
/>
</g>
)}
</g>
);
}
TSXcomponents/charts/price-target-fan/tooltip.tsx
"use client";
import { type ReactNode, useState } from "react";
import { Tooltip } from "@/components/motion/tooltip";
import { cn } from "@/lib/utils";
import { usePriceTargetFan } from "./context";
import { H, ink, W } from "./utils";
export function PriceTargetFanTooltip({
children,
className,
}: {
children?: ReactNode | ((data: NonNullable<ReturnType<typeof usePriceTargetFan>["overlay"]>) => ReactNode);
className?: string;
}) {
const { svgRef, tooltipId, overlay } = usePriceTargetFan();
const [dismissed, setDismissed] = useState<typeof overlay>(null);
return (
<Tooltip
open={overlay !== null && dismissed !== overlay}
onOpenChange={(open) => { if (!open) setDismissed(overlay); }}
id={tooltipId}
anchorRef={svgRef}
anchorPoint={{ x: (overlay?.px ?? 0) / W, y: (overlay?.py ?? 0) / H }}
className={cn("w-[148px]", className)}
content={overlay &&
(typeof children === "function"
? children(overlay)
: (children ?? (
<>
<span className="block text-[10px] text-muted-foreground">{overlay.title}</span>
<span className="mt-1.5 flex flex-col gap-1">
{overlay.metrics.map((metric) => (
<span key={metric.label} className="flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-foreground">{metric.label}</span>
<span className="font-mono tabular-nums text-foreground">{metric.value}</span>
</span>
))}
</span>
{overlay.accent ? (
<span className="mt-1.5 flex items-center justify-between gap-3 border-t border-border pt-1.5 text-xs">
<span className="font-medium text-foreground">{overlay.accent.label}</span>
<span className="font-mono tabular-nums" style={{ color: ink(overlay.accent.color) }}>
{overlay.accent.value}
</span>
</span>
) : null}
</>
)))}
/>
);
}
TSXcomponents/charts/price-target-fan/types.ts
import type { ReactNode } from "react";
export interface PriceTarget {
key: string;
price: number;
analysts: number;
/** Any CSS color; the projection, its dot and its label take it. */
color?: string;
}
export interface PriceHistoryPoint {
date: string;
price: number;
}
export type PriceTargetFanActive = { type: "history"; date: string } | { type: "target"; key: string };
export interface PriceTargetFanProps {
/** Accessible name of the chart. */
label?: string;
/** Last traded price; the history walks to this point. */
current: number;
/** High, mean and low targets, in that order. */
targets: [PriceTarget, PriceTarget, PriceTarget];
/** Axis labels, oldest to horizon. */
dates?: { start?: string; mid?: string; horizon?: string };
/** Actual prices, oldest first, using ISO dates. Empty history renders only the current price and targets. */
history?: PriceHistoryPoint[];
children?: ReactNode;
active?: PriceTargetFanActive | null;
defaultActive?: PriceTargetFanActive | null;
onActiveChange?: (active: PriceTargetFanActive | null) => void;
className?: string;
}
TSXcomponents/charts/price-target-fan/utils.ts
import type { PriceHistoryPoint } from "./types";
/** e.g. "Mon, Jul 15" — the scrub read-out's date. */
export const fmtScrubDate = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
weekday: "short",
month: "short",
day: "numeric",
});
export const UP = "var(--success)";
export const MID = "var(--accent)";
export const LOW = "var(--warning)";
export const W = 520;
export const H = 236;
export const PAD = { l: 36, r: 108, t: 16, b: 28 };
/** 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)`;
export const EMPTY_HISTORY: PriceHistoryPoint[] = [];
export const fmtAxisDate = (date: string) =>
new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", year: "numeric" }).format(
new Date(date),
);
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 {
cloneElement,
isValidElement,
type PointerEvent,
type ReactElement,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { TooltipSurface } from "@/components/motion/tooltip-surface";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children?: ReactElement;
/** Existing trigger for controlled integrations such as chart cells. */
anchorRef?: RefObject<HTMLElement | SVGElement | null>;
/** Point within the anchor, as fractions of its rendered width and height. */
anchorPoint?: { x: number; y: number };
open?: boolean;
onOpenChange?: (open: boolean) => void;
id?: string;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
anchorRef: externalAnchorRef,
anchorPoint,
open: controlledOpen,
onOpenChange,
id: providedId,
}: TooltipProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (controlledOpen === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[controlledOpen, onOpenChange],
);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const generatedId = useId();
const id = providedId ?? generatedId;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapperRef = useRef<HTMLSpanElement>(null);
const anchorRef = externalAnchorRef ?? wrapperRef;
const hover = useHoverGesture();
const surfaceRef = useRef<HTMLSpanElement>(null);
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width * (anchorPoint?.x ?? 0.5);
const cy = r.top + r.height * (anchorPoint?.y ?? 0.5);
const point: Record<Side, { top: number; left: number }> = {
top: { top: (anchorPoint ? cy : r.top) - GAP, left: cx },
bottom: { top: (anchorPoint ? cy : r.bottom) + GAP, left: cx },
left: { top: cy, left: (anchorPoint ? cx : r.left) - GAP },
right: { top: cy, left: (anchorPoint ? cx : r.right) + GAP },
};
const next = point[side];
const width = surfaceRef.current?.offsetWidth ?? 0;
const height = surfaceRef.current?.offsetHeight ?? 0;
const dx = side === "left" ? width : side === "right" ? 0 : width / 2;
const dy = side === "top" ? height : side === "bottom" ? 0 : height / 2;
next.left = Math.max(GAP + dx, Math.min(next.left, window.innerWidth - GAP - width + dx));
next.top = Math.max(GAP + dy, Math.min(next.top, window.innerHeight - GAP - height + dy));
setCoords(previous => previous?.top === next.top && previous.left === next.left ? previous : next);
}, [side, anchorRef, anchorPoint]);
const positioned = coords !== null;
useLayoutEffect(() => {
if (!open) return;
place();
const observer = new ResizeObserver(place);
if (anchorRef.current) observer.observe(anchorRef.current);
if (positioned && surfaceRef.current) observer.observe(surfaceRef.current);
return () => observer.disconnect();
}, [open, place, anchorRef, positioned]);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [delay, place, setOpen]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) lastHiddenAt = Date.now();
setOpen(false);
}, [open, setOpen]);
// A finger never hovers, and Safari does not focus a button on tap either, so
// the label is only reachable if the tap itself opens the tooltip. A click
// carries no pointerType, so the pointerdown that preceded it is what says
// whether this was a tap; keyboard activation arrives with no pointerdown at
// all, and focus has already shown the label there.
const tap = useTapGesture<boolean>();
const toggleOnTap = useCallback(() => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
if (gesture.state) {
hide();
return;
}
if (timer.current) clearTimeout(timer.current);
place();
setOpen(true);
}, [hide, place, tap, setOpen]);
// ...and closed again by the next tap that lands somewhere else. The label
// covers nothing interactive, so that tap passes through to what it hit.
useDismiss(open, hide, anchorRef);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
if (!externalAnchorRef && !isValidElement(children)) return children;
// The label describes the trigger, so it has to name the trigger itself.
// Everything else the tooltip needs is read off the anchor below instead of
// cloned on: a handler written onto the child is the child's handler as far
// as that child can tell, and a component that owns its activation —
// hard-wiring onClick and spreading the rest of its props over it, as
// ThemeToggle does — then runs the tooltip's instead of its own. Composing
// with `props.onClick` cannot save it either, because a component element's
// props hold nothing the component does internally.
const trigger = isValidElement(children)
? cloneElement(children as ReactElement<Record<string, unknown>>, {
"aria-describedby": id,
})
: null;
return (
<>
{!externalAnchorRef ? (
// biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.
<span
ref={wrapperRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
// Pointer events, not the mouse pair: a tap fires compatibility
// mouseenter/mouseleave that carry no pointerType, which raced the tap
// path into opening and closing the same label.
onPointerEnter={(event: PointerEvent) => {
if (hover.enter(event)) show();
}}
onPointerLeave={(event: PointerEvent) => {
if (hover.leave(event)) hide();
}}
onFocus={show}
onBlur={hide}
onPointerDown={(event: PointerEvent) => tap.start(event, open)}
// A gesture the platform took away sends no click, and a key press
// starts an activation that never had a pointer behind it. Either way
// the record has to go, or the next click reads a finger that has long
// since lifted.
onPointerCancel={tap.drop}
onKeyDown={tap.drop}
onClick={toggleOnTap}
>
{trigger}
</span>
) : null}
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<TooltipSurface
ref={surfaceRef}
id={id}
side={side}
style={{ transformOrigin: transformOrigin[side], maxWidth: "calc(100vw - 16px)", whiteSpace: "normal" }}
className={className}
>
{content}
</TooltipSurface>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
TSXcomponents/motion/tooltip-surface.tsx
"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { useMemo, type ComponentProps, type ReactNode, type Ref } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
// Small tooltip surfaces need the lighter spawn used by the original Tooltip.
const TOOLTIP_SPRING = { type: "spring", stiffness: 380, damping: 30, mass: 0.7 } as const;
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
...TOOLTIP_SPRING,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
/** The shared visual surface for trigger tooltips and chart readouts. Positioning belongs to the caller. */
export function TooltipSurface({
children,
side = "top",
className,
ref,
...props
}: Omit<ComponentProps<typeof motion.span>, "children"> & {
children?: ReactNode;
side?: Side;
ref?: Ref<HTMLSpanElement>;
}) {
const reduce = useReducedMotion();
const variants = useMemo(() => reduce ? REDUCED_VARIANTS : buildVariants(side), [reduce, side]);
return (
<motion.span
ref={ref}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
{...props}
>
{children}
</motion.span>
);
}
API Reference
PriceTargetFan
label?stringAccessible name of the chart.
—currentnumberLast traded price; the history walks to this point.
—targets[PriceTarget, PriceTarget, PriceTarget]High, mean and low targets, in that order.
—dates?{ start?: string; mid?: string; horizon?: string | undefined; } | undefinedAxis labels, oldest to horizon.
—history?{}Actual prices, oldest first, using ISO dates. Empty history renders only the current price and targets.
—active?PriceTargetFanActive | null—defaultActive?PriceTargetFanActive | null—onActiveChange?((active: PriceTargetFanActive | null) => void)—className?string—PriceTargetFanHeader
className?string—PriceTargetFanAxes
className?string—PriceTargetFanPlot
className?string—PriceTargetFanSvg
className?string—PriceTargetFanCursor
className?string—PriceTargetFanHistory
className?string—PriceTargetFanNow
className?string—PriceTargetFanTargets
className?string—PriceTargetFanTooltip
className?string—Contributed by
SavvaUpdated