shadimage
beui
componentprice-target-fansource: ok · hit

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.

npx shadcn add "https://beui.dev/r/price-target-fan.json"
free

Where it goes

navbarpricingkanban

Interaction

tooltipcursor-follow

Use cases

saas-landing

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files21 · source cached 10/15/2026, 5:37:15 AM

components/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";
components/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;
}
components/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>
  );
}
components/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>
  );
}
components/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>
  );
}
components/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}
              </>
            )))}
    />
  );
}
components/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;
}
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
components/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),
  );
lib/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;
}
components/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;
  /**
   * Fraction digits to keep. The value is rounded to this many places before
   * `format` runs (default 0, whole numbers).
   */
  decimals?: number;
  /** Per-digit roll duration in seconds. */
  duration?: number;
  /** Stagger between digits. */
  stagger?: number;
  /** Render only after the element enters the viewport. */
  startOnView?: boolean;
  prefix?: string;
  suffix?: string;
  /** Add a small blur during digit rolls. */
  blur?: boolean;
  className?: string;
  digitClassName?: string;
  /** Insert locale group separators (commas). Server-component safe. */
  locale?: boolean;
  /** Custom formatter. Client-only — server components must use `locale` instead. */
  format?: (value: number) => string;
}

const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);

export function NumberTicker({
  value,
  pad,
  decimals = 0,
  duration = 0.9,
  stagger = 0.04,
  startOnView = true,
  prefix,
  suffix,
  blur = false,
  className,
  digitClassName,
  locale,
  format,
}: NumberTickerProps) {
  const containerRef = useRef<HTMLSpanElement>(null);
  const inView = useInView(containerRef, { once: true, amount: 0.6 });
  const [armed, setArmed] = useState(!startOnView);

  useEffect(() => {
    if (startOnView && inView) setArmed(true);
  }, [startOnView, inView]);

  const text = useMemo(() => {
    const places = Math.max(0, Math.floor(decimals));
    const factor = 10 ** places;
    const rounded = Math.round(value * factor) / factor;
    const formatted = format
      ? format(rounded)
      : locale
        ? rounded.toLocaleString(undefined, {
            minimumFractionDigits: places,
            maximumFractionDigits: places,
          })
        : rounded.toFixed(places);
    return pad ? formatted.padStart(pad, "0") : formatted;
  }, [value, pad, decimals, format, locale]);
  const glyphs = useMemo(() => {
    const chars = text.split("");
    // Key by place value (position from the right): a changing digit keeps its
    // identity and rolls to the new value instead of remounting and replaying
    // from 0. Growing numbers add glyphs on the left without re-keying the
    // ones, tens, hundreds already on screen.
    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
  }, [text]);
  const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;

  // Stagger is an entrance flourish. Once the reveal has played, value
  // changes roll every digit immediately — a per-digit delay on live updates
  // reads as lag.
  const [entered, setEntered] = useState(false);
  useEffect(() => {
    if (!armed || entered) return;
    const total = (duration + glyphs.length * stagger) * 1000;
    const t = window.setTimeout(() => setEntered(true), total);
    return () => window.clearTimeout(t);
  }, [armed, entered, duration, stagger, glyphs.length]);

  return (
    <span
      ref={containerRef}
      className={cn("inline-flex items-baseline tabular-nums", className)}
    >
      <span className="sr-only">{readableText}</span>
      <span aria-hidden="true" className="inline-flex items-baseline">
        {prefix ? <span>{prefix}</span> : null}
        {glyphs.map(({ char, id }, i) => {
          const isDigit = /\d/.test(char);
          if (!isDigit) {
            return (
              <span key={id} className="inline-block">
                {char}
              </span>
            );
          }
          const digit = Number(char);
          return (
            <Digit
              key={id}
              digit={armed ? digit : 0}
              delay={entered ? 0 : i * stagger}
              duration={duration}
              blur={blur}
              className={digitClassName}
            />
          );
        })}
        {suffix ? <span>{suffix}</span> : null}
      </span>
    </span>
  );
}

function Digit({
  digit,
  delay,
  duration,
  blur,
  className,
}: {
  digit: number;
  delay: number;
  duration: number;
  blur: boolean;
  className?: string;
}) {
  const reduce = useReducedMotion();
  const columnRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
      return;
    }

    const node = columnRef.current;
    const controls = animate(
      node,
      { filter: ["blur(10px)", "blur(0px)"] },
      {
        duration: Math.min(duration * 0.75, 0.32),
        delay,
        ease: EASE_OUT,
      },
    );

    return () => {
      controls.stop();
      node.style.filter = "blur(0px)";
    };
  }, [blur, delay, digit, duration, reduce]);

  return (
    // The invisible in-flow digit gives the slot a real text baseline so it
    // lines up with surrounding text; clip-path clips the rolling column
    // without moving that baseline the way overflow: hidden would.
    <span
      className={cn(
        "relative inline-block text-center [clip-path:inset(0)]",
        className,
      )}
      style={{
        height: `${DIGIT_HEIGHT_EM}em`,
        lineHeight: `${DIGIT_HEIGHT_EM}em`,
        width: "1ch",
      }}
    >
      <span className="invisible">{digit}</span>
      <motion.span
        ref={columnRef}
        initial={{ y: 0 }}
        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
        transition={
          reduce
            ? { duration: 0 }
            : { duration, delay, ease: EASE_OUT }
        }
        className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
      >
        {DIGITS.map((n) => (
          <span
            key={n}
            className="flex h-[1.1em] items-center justify-center leading-none"
          >
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}
lib/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;
components/motion/tooltip.tsx
"use client";

import { AnimatePresence } from "motion/react";
import {
  type ComponentProps,
  type PointerEvent as ReactPointerEvent,
  cloneElement,
  createContext,
  isValidElement,
  type ReactElement,
  type ReactNode,
  type Ref,
  type RefObject,
  useCallback,
  useContext,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { createPortal } from "react-dom";
import { TooltipSurface } from "@/components/motion/tooltip-surface";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
import { focusFromKeyboard, graceArea, inBox, inPolygon, watchModality } from "./tooltip/grace";
import { TooltipArrow, TooltipPositioner } from "./tooltip/positioner";
import {
  type TooltipAlign,
  type TooltipAnchor,
  type TooltipPoint,
  type TooltipSide,
  useTooltipPointer,
} from "./tooltip/use-position";

export type { TooltipAlign, TooltipSide };

/* -------------------------------------------------------------------------- */
/* Provider                                                                   */
/* -------------------------------------------------------------------------- */

interface TooltipTiming {
  /** Delay before a hover opens a tooltip (ms). */
  delay: number;
  /** Delay before a tooltip closes once the pointer has left it (ms). */
  closeDelay: number;
  /** After one tooltip closes, others open instantly for this long (ms). */
  skipDelay: number;
}

const TimingContext = createContext<TooltipTiming>({ delay: 120, closeDelay: 0, skipDelay: 300 });

export interface TooltipProviderProps extends Partial<TooltipTiming> {
  children: ReactNode;
}

/** Shared timing for every tooltip inside it. Optional: tooltips work without one. */
export function TooltipProvider({ children, ...timing }: TooltipProviderProps) {
  const parent = useContext(TimingContext);
  const { delay = parent.delay, closeDelay = parent.closeDelay, skipDelay = parent.skipDelay } =
    timing;
  const value = useMemo(() => ({ delay, closeDelay, skipDelay }), [delay, closeDelay, skipDelay]);
  return <TimingContext.Provider value={value}>{children}</TimingContext.Provider>;
}

/* -------------------------------------------------------------------------- */
/* Root                                                                       */
/* -------------------------------------------------------------------------- */

// Moving along a toolbar should feel instant after the first label, and only
// one label may show at a time: a keyboard-focused control and a hovered one
// would otherwise both claim the screen.
let lastClosedAt = 0;
let current: { close: () => void } | null = null;

function isEmpty(content: ReactNode) {
  return content == null || content === false || content === "";
}

interface TooltipContextValue {
  open: boolean;
  contentId: string;
  labelId: string;
  asLabel: boolean;
  empty: boolean;
  followCursor: boolean;
  anchor: TooltipAnchor | null;
  /** True when a TooltipTrigger owns the anchor; false for an external `anchorRef`. */
  triggered: boolean;
  floatingRef: RefObject<HTMLSpanElement | null>;
  pointer: ReturnType<typeof useTooltipPointer>;
  setTrigger: (element: TooltipAnchor | null) => void;
  setEmpty: (empty: boolean) => void;
  close: () => void;
  onContentEnter: () => void;
  onContentLeave: (event: ReactPointerEvent) => void;
}

const TooltipContext = createContext<TooltipContextValue | null>(null);

function useTooltipContext(part: string) {
  const context = useContext(TooltipContext);
  if (!context) throw new Error(`${part} must be used inside <Tooltip>`);
  return context;
}

export interface TooltipRootProps {
  children?: ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Delay before showing on hover (ms). Keyboard focus shows immediately. */
  delay?: number;
  /** Delay before hiding once the pointer has left (ms). */
  closeDelay?: number;
  /** Never show, e.g. while the label it carries is already visible. */
  disabled?: boolean;
  /**
   * Use the tooltip as the trigger's accessible name rather than its
   * description. For icon-only controls whose only label is the tooltip.
   */
  asLabel?: boolean;
  /** Follow real pointer coordinates; keyboard focus still uses the anchor. */
  followCursor?: boolean;
  /** Existing anchor for controlled integrations such as chart cells. */
  anchorRef?: RefObject<TooltipAnchor | null>;
  /** Id of the tooltip surface, for anchors that reference it themselves. */
  id?: string;
}

function TooltipRoot({
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  delay: delayProp,
  closeDelay: closeDelayProp,
  disabled = false,
  asLabel = false,
  followCursor = false,
  anchorRef,
  id,
  initialEmpty = false,
}: TooltipRootProps & { initialEmpty?: boolean }) {
  const timing = useContext(TimingContext);
  const delay = delayProp ?? timing.delay;
  const closeDelay = closeDelayProp ?? timing.closeDelay;
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
  const open = controlledOpen ?? uncontrolledOpen;
  const [empty, setEmpty] = useState(initialEmpty);
  const [trigger, setTrigger] = useState<TooltipAnchor | null>(null);
  const [external, setExternal] = useState<TooltipAnchor | null>(null);
  const anchor = anchorRef ? external : trigger;
  const generatedId = useId();
  const contentId = id ?? `${generatedId}-content`;
  const labelId = `${generatedId}-label`;
  const floatingRef = useRef<HTMLSpanElement | null>(null);
  const anchorObject = useRef<TooltipAnchor | null>(null);
  const pointer = useTooltipPointer(anchor, followCursor);

  // An external ref can attach after this component renders; read it once it has.
  useLayoutEffect(() => {
    if (!anchorRef) return;
    const element = anchorRef.current ?? null;
    if (element !== external) setExternal(element);
  });

  // Everything the event listeners read, current without re-binding them.
  const latest = useRef({ open, delay, closeDelay, disabled, empty, onOpenChange, controlled: false });
  useLayoutEffect(() => {
    anchorObject.current = anchor;
    latest.current = {
      open,
      delay,
      closeDelay,
      disabled,
      empty,
      onOpenChange,
      controlled: controlledOpen !== undefined,
    };
  });

  const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const grace = useRef<(() => void) | null>(null);
  const self = useRef<{ close: () => void }>({ close: () => {} });

  const clearTimers = useCallback(() => {
    if (openTimer.current) clearTimeout(openTimer.current);
    if (closeTimer.current) clearTimeout(closeTimer.current);
    openTimer.current = null;
    closeTimer.current = null;
  }, []);

  const stopGrace = useCallback(() => {
    grace.current?.();
    grace.current = null;
  }, []);

  // Only a real change reaches the consumer: a pointer leaving a closed
  // tooltip is not news.
  const commit = useCallback((next: boolean) => {
    if (latest.current.open === next) return;
    if (!latest.current.controlled) setUncontrolledOpen(next);
    latest.current.onOpenChange?.(next);
  }, []);

  const openNow = useCallback(() => {
    clearTimers();
    stopGrace();
    if (latest.current.disabled || latest.current.empty) return;
    if (current && current !== self.current) current.close();
    current = self.current;
    commit(true);
  }, [clearTimers, stopGrace, commit]);

  const close = useCallback(() => {
    clearTimers();
    stopGrace();
    if (latest.current.open) lastClosedAt = Date.now();
    if (current === self.current) current = null;
    commit(false);
  }, [clearTimers, stopGrace, commit]);
  useLayoutEffect(() => {
    self.current.close = close;
  }, [close]);

  const requestOpen = useCallback(() => {
    clearTimers();
    if (latest.current.open || latest.current.disabled || latest.current.empty) return;
    const warm = Date.now() - lastClosedAt < timing.skipDelay;
    if (warm || latest.current.delay <= 0) openNow();
    else openTimer.current = setTimeout(openNow, latest.current.delay);
  }, [clearTimers, openNow, timing.skipDelay]);

  const requestClose = useCallback(() => {
    clearTimers();
    if (latest.current.closeDelay > 0) {
      closeTimer.current = setTimeout(close, latest.current.closeDelay);
    } else close();
  }, [clearTimers, close]);

  // Hold the label open while the pointer crosses from one box toward the other.
  const startGrace = useCallback(
    (exit: TooltipPoint, from: Element, to: Element | null) => {
      stopGrace();
      if (!to) return requestClose();
      const target = to.getBoundingClientRect();
      const origin = from.getBoundingClientRect();
      const corridor = graceArea(exit, target);
      const onMove = (event: PointerEvent) => {
        const point = { x: event.clientX, y: event.clientY };
        if (inBox(point, target) || inBox(point, origin)) return;
        if (!inPolygon(point, corridor)) requestClose();
      };
      document.addEventListener("pointermove", onMove);
      grace.current = () => document.removeEventListener("pointermove", onMove);
    },
    [stopGrace, requestClose],
  );

  const hover = useHoverGesture();
  const tap = useTapGesture<boolean>();

  // Listeners go on the trigger itself rather than on cloned props, so a
  // component that owns its own handlers keeps them, and no wrapper element
  // sits between the control and its layout.
  useEffect(() => {
    if (anchorRef || !trigger) return;
    watchModality();
    // A mouse press dismisses the label and keeps it away until the pointer
    // leaves; hovering a control you just clicked should not bring it back.
    let suppressed = false;
    // Focus that came from the keyboard holds the label open on its own.
    let keyboardFocus = false;

    const onEnter = (event: PointerEvent) => {
      if (!hover.enter(event)) return;
      stopGrace();
      if (closeTimer.current) clearTimeout(closeTimer.current);
      if (!suppressed) requestOpen();
    };
    const onLeave = (event: PointerEvent) => {
      if (!hover.leave(event)) return;
      suppressed = false;
      if (keyboardFocus) return;
      if (openTimer.current) {
        clearTimers();
        return;
      }
      if (latest.current.open && !followCursor) {
        startGrace({ x: event.clientX, y: event.clientY }, trigger, floatingRef.current);
      } else requestClose();
    };
    const onDown = (event: PointerEvent) => {
      tap.start(event, latest.current.open);
      if (event.pointerType === "mouse") {
        suppressed = true;
        close();
      }
    };
    // A finger never hovers, and Safari does not focus a button on tap either,
    // so the label is only reachable if the tap itself toggles it. The click
    // carries no pointerType; the pointerdown before it says whether it was a
    // tap, and keyboard activation has none at all.
    const onClick = () => {
      const gesture = tap.take();
      if (!gesture || gesture.pointerType === "mouse") return;
      if (gesture.state) close();
      else openNow();
    };
    const onKey = () => tap.drop();
    const onFocusIn = () => {
      if (!focusFromKeyboard()) return;
      keyboardFocus = true;
      openNow();
    };
    const onFocusOut = (event: FocusEvent) => {
      if (trigger.contains(event.relatedTarget as Node | null)) return;
      keyboardFocus = false;
      close();
    };

    // Both element kinds dispatch the same events; one type keeps the listeners typed.
    const el = trigger as HTMLElement;
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    el.addEventListener("pointerdown", onDown);
    el.addEventListener("pointercancel", tap.drop);
    el.addEventListener("click", onClick);
    el.addEventListener("keydown", onKey);
    el.addEventListener("focusin", onFocusIn);
    el.addEventListener("focusout", onFocusOut);
    return () => {
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
      el.removeEventListener("pointerdown", onDown);
      el.removeEventListener("pointercancel", tap.drop);
      el.removeEventListener("click", onClick);
      el.removeEventListener("keydown", onKey);
      el.removeEventListener("focusin", onFocusIn);
      el.removeEventListener("focusout", onFocusOut);
    };
  }, [
    anchorRef,
    trigger,
    followCursor,
    hover,
    tap,
    requestOpen,
    requestClose,
    openNow,
    close,
    clearTimers,
    stopGrace,
    startGrace,
  ]);

  // While open, a trigger that turns disabled loses focus without a blur event,
  // and a window that loses focus never reports the pointer leaving.
  useEffect(() => {
    if (!open || anchorRef || !trigger) return;
    const observer = new MutationObserver(() => {
      if (trigger.matches(":disabled, [aria-disabled='true']")) close();
    });
    observer.observe(trigger, { attributes: true, attributeFilter: ["disabled", "aria-disabled"] });
    window.addEventListener("blur", close);
    return () => {
      observer.disconnect();
      window.removeEventListener("blur", close);
    };
  }, [open, anchorRef, trigger, close]);

  // A chart drives its own open state and swaps content as it goes; only a
  // trigger-owned label closes itself for having nothing to say.
  useEffect(() => {
    if (disabled || (empty && !anchorRef)) close();
  }, [disabled, empty, anchorRef, close]);

  useEffect(
    () => () => {
      clearTimers();
      stopGrace();
      if (current === self.current) current = null;
    },
    [clearTimers, stopGrace],
  );

  const insideTooltip = useCallback(
    (target: Element) => Boolean(floatingRef.current?.contains(target)),
    [],
  );
  // ...and closed by the next tap that lands somewhere else, or Escape. The
  // label covers nothing interactive, so that tap passes through to what it hit.
  useDismiss(open, close, anchorObject, { ignore: insideTooltip });

  const onContentEnter = useCallback(() => {
    stopGrace();
    clearTimers();
  }, [stopGrace, clearTimers]);

  const onContentLeave = useCallback(
    (event: ReactPointerEvent) => {
      const floating = floatingRef.current;
      if (!floating) return;
      if (anchorRef) {
        // Bridge the small gap back onto a chart; it reopens on its own terms.
        clearTimers();
        closeTimer.current = setTimeout(close, 100);
        return;
      }
      startGrace({ x: event.clientX, y: event.clientY }, floating, anchorObject.current);
    },
    [anchorRef, clearTimers, close, startGrace],
  );

  const value = useMemo<TooltipContextValue>(
    () => ({
      open,
      contentId,
      labelId,
      asLabel,
      empty,
      followCursor,
      anchor,
      triggered: !anchorRef,
      floatingRef,
      pointer,
      setTrigger,
      setEmpty,
      close,
      onContentEnter,
      onContentLeave,
    }),
    [
      open,
      contentId,
      labelId,
      asLabel,
      empty,
      followCursor,
      anchor,
      anchorRef,
      pointer,
      close,
      onContentEnter,
      onContentLeave,
    ],
  );

  return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}

/* -------------------------------------------------------------------------- */
/* Trigger                                                                    */
/* -------------------------------------------------------------------------- */

function mergeIds(...ids: unknown[]) {
  return ids.filter((value) => typeof value === "string" && value).join(" ") || undefined;
}

function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as { current: T | null }).current = value;
}

export interface TooltipTriggerProps extends ComponentProps<"button"> {
  /** Use the single child element as the trigger instead of rendering a button. */
  asChild?: boolean;
}

export function TooltipTrigger({ asChild = false, children, ref, ...props }: TooltipTriggerProps) {
  const { open, contentId, labelId, asLabel, empty, setTrigger } =
    useTooltipContext("TooltipTrigger");
  const child = asChild && isValidElement(children) ? children : null;
  const childProps = (child?.props ?? props) as Record<string, unknown>;
  const childRef = child ? (childProps.ref as Ref<TooltipAnchor> | undefined) : undefined;
  const attached = useRef<TooltipAnchor | null>(null);

  const setRef = useCallback(
    (element: TooltipAnchor | null) => {
      attached.current = element;
      setTrigger(element);
      assignRef(childRef ?? (ref as Ref<TooltipAnchor> | undefined), element);
    },
    [setTrigger, childRef, ref],
  );

  useEffect(() => {
    if (process.env.NODE_ENV !== "production" && child && !attached.current) {
      console.warn(
        "TooltipTrigger: the child did not attach a ref, so the tooltip has nothing to listen to. Render a DOM element or a component that passes `ref` through.",
      );
    }
  }, [child]);

  const aria = asLabel
    ? {
        "aria-labelledby": empty
          ? (childProps["aria-labelledby"] as string | undefined)
          : labelId,
      }
    : {
        "aria-describedby": mergeIds(
          childProps["aria-describedby"],
          open && !empty ? contentId : undefined,
        ),
      };

  if (asChild) {
    if (!child) return children;
    return cloneElement(child as ReactElement<Record<string, unknown>>, { ref: setRef, ...aria });
  }

  return (
    <button
      type="button"
      data-state={open ? "open" : "closed"}
      {...props}
      ref={setRef as Ref<HTMLButtonElement>}
      {...aria}
    >
      {children}
    </button>
  );
}

/* -------------------------------------------------------------------------- */
/* Content                                                                    */
/* -------------------------------------------------------------------------- */

export interface TooltipContentProps {
  children?: ReactNode;
  side?: TooltipSide;
  align?: TooltipAlign;
  /** Gap between anchor and tooltip (px). */
  sideOffset?: number;
  /** Draw an arrow pointing at the anchor. */
  arrow?: boolean;
  /** Point within the anchor, as fractions of its rendered width and height. */
  anchorPoint?: TooltipPoint;
  className?: string;
}

export function TooltipContent({
  children,
  side = "top",
  align = "center",
  sideOffset,
  arrow = false,
  anchorPoint,
  className,
}: TooltipContentProps) {
  const {
    open,
    contentId,
    labelId,
    asLabel,
    followCursor,
    anchor,
    floatingRef,
    pointer,
    setEmpty,
    close,
    onContentEnter,
    onContentLeave,
  } = useTooltipContext("TooltipContent");
  const empty = isEmpty(children);
  const arrowRef = useRef<SVGSVGElement | null>(null);

  useLayoutEffect(() => {
    setEmpty(empty);
  }, [empty, setEmpty]);

  if (typeof document === "undefined") return null;

  return createPortal(
    <>
      {/* The name has to exist while the label is closed, so it lives apart from the surface. */}
      {asLabel && !empty ? (
        <span id={labelId} hidden>
          {children}
        </span>
      ) : null}
      <AnimatePresence>
        {open && !empty ? (
          <TooltipPositioner
            key="tooltip"
            anchor={anchor}
            floatingRef={floatingRef}
            arrowRef={arrow ? arrowRef : undefined}
            anchorPoint={anchorPoint}
            followCursor={followCursor}
            side={side}
            align={align}
            sideOffset={sideOffset}
            onDismiss={close}
            pointer={pointer}
          >
            {(positioned, isPresent, placedSide) => (
              <TooltipSurface
                id={asLabel ? undefined : contentId}
                aria-hidden={asLabel || undefined}
                ready={positioned}
                onPointerEnter={onContentEnter}
                onPointerLeave={onContentLeave}
                style={{ pointerEvents: isPresent && !followCursor ? "auto" : "none" }}
                className={cn(
                  "max-w-[min(20rem,calc(100vw-16px))] whitespace-normal break-words",
                  arrow && "relative",
                  className,
                )}
              >
                {children}
                {arrow ? <TooltipArrow ref={arrowRef} side={placedSide} /> : null}
              </TooltipSurface>
            )}
          </TooltipPositioner>
        ) : null}
      </AnimatePresence>
    </>,
    document.body,
  );
}

/* -------------------------------------------------------------------------- */
/* Tooltip (root, or one-line shorthand when given `content`)                 */
/* -------------------------------------------------------------------------- */

export interface TooltipProps
  extends TooltipRootProps,
    Omit<TooltipContentProps, "children" | "className"> {
  /**
   * Shorthand: the tooltip's content. With it, `children` is the trigger
   * element; without it, compose `TooltipTrigger` and `TooltipContent`.
   */
  content?: ReactNode;
  /** Shorthand only: classes for the tooltip surface. */
  className?: string;
}

export function Tooltip(props: TooltipProps) {
  if (!("content" in props)) return <TooltipRoot {...props} />;
  const {
    content,
    children,
    side,
    align,
    sideOffset,
    arrow,
    anchorPoint,
    className,
    ...root
  } = props;
  if (!root.anchorRef && !isValidElement(children)) return children ?? null;
  return (
    <TooltipRoot {...root} initialEmpty={isEmpty(content)}>
      {isValidElement(children) ? <TooltipTrigger asChild>{children}</TooltipTrigger> : null}
      <TooltipContent
        side={side}
        align={align}
        sideOffset={sideOffset}
        arrow={arrow}
        anchorPoint={anchorPoint}
        className={className}
      >
        {content}
      </TooltipContent>
    </TooltipRoot>
  );
}
components/motion/tooltip/grace.ts
type Point = { x: number; y: number };
type Box = { left: number; top: number; right: number; bottom: number };

/**
 * The corridor a pointer may cross between the trigger and the open tooltip
 * without closing it: the convex hull of where it left and the box it is
 * heading for. A fixed timeout either cuts a slow diagonal move short or keeps
 * the label alive after the pointer has plainly gone elsewhere; this follows
 * the path itself.
 */
export function graceArea(exit: Point, target: Box): Point[] {
  // A few pixels either side of the exit point give the corridor some width at
  // its narrow end, so a hand that drifts sideways while it moves stays inside.
  const pad = 5;
  return hull([
    { x: exit.x - pad, y: exit.y - pad },
    { x: exit.x + pad, y: exit.y - pad },
    { x: exit.x - pad, y: exit.y + pad },
    { x: exit.x + pad, y: exit.y + pad },
    { x: target.left, y: target.top },
    { x: target.right, y: target.top },
    { x: target.right, y: target.bottom },
    { x: target.left, y: target.bottom },
  ]);
}

export function inBox(point: Point, box: Box) {
  return (
    point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom
  );
}

export function inPolygon(point: Point, polygon: Point[]) {
  let inside = false;
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    const a = polygon[i];
    const b = polygon[j];
    if (
      a.y > point.y !== b.y > point.y &&
      point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
    ) {
      inside = !inside;
    }
  }
  return inside;
}

// Monotone chain; the corridor is always convex, so a hull is all it needs.
function hull(points: Point[]) {
  const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
  const cross = (o: Point, a: Point, b: Point) =>
    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
  const lower: Point[] = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
      lower.pop();
    }
    lower.push(p);
  }
  const upper: Point[] = [];
  for (let i = sorted.length - 1; i >= 0; i--) {
    const p = sorted[i];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
      upper.pop();
    }
    upper.push(p);
  }
  lower.pop();
  upper.pop();
  return lower.concat(upper);
}

/**
 * How the user last reached for the page. A focus that a mouse press caused is
 * not a request to read the label: the pointer is already on the control and
 * hovering showed it. A focus that followed a key — Tab, or a roving arrow key
 * moving focus in script — is the only way a keyboard user sees it at all.
 */
let modality: "keyboard" | "pointer" = "keyboard";
let listening = false;

export function watchModality() {
  if (listening || typeof window === "undefined") return;
  listening = true;
  window.addEventListener("keydown", () => (modality = "keyboard"), true);
  window.addEventListener("pointerdown", () => (modality = "pointer"), true);
}

export function focusFromKeyboard() {
  return modality === "keyboard";
}
components/motion/tooltip/positioner.tsx
"use client";

import { useIsPresent } from "motion/react";
import { type CSSProperties, type ReactNode, type Ref, useCallback, useState } from "react";
import { type TooltipSide, useTooltipPosition } from "./use-position";

type PositionProps = Parameters<typeof useTooltipPosition>[0];

/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */
export function TooltipPositioner({
  children,
  ...position
}: Omit<PositionProps, "open" | "onPosition"> & {
  children: (ready: boolean, present: boolean, side: TooltipSide) => ReactNode;
}) {
  const present = useIsPresent();
  const [ready, setReady] = useState(false);
  const [side, setSide] = useState<TooltipSide>(position.side);
  const onPosition = useCallback((placed: TooltipSide) => {
    setReady(true);
    setSide(placed);
  }, []);
  useTooltipPosition({ ...position, open: present, onPosition });
  return (
    <span
      ref={position.floatingRef}
      inert={!present}
      aria-hidden={!present || undefined}
      className="pointer-events-none fixed left-0 top-0 z-[9999] w-max"
      style={{ visibility: "hidden", maxWidth: "calc(100vw - 16px)" }}
    >
      {children(ready, present, side)}
    </span>
  );
}

// Each arrow is a 12px box whose base sits on the surface's inner border edge,
// so its fill covers the border beneath it and only the two outer edges stroke.
const ARROW: Record<TooltipSide, { path: string; style: CSSProperties }> = {
  top: { path: "M0 6L6 12L12 6", style: { top: "calc(100% - 6px)" } },
  bottom: { path: "M0 6L6 0L12 6", style: { bottom: "calc(100% - 6px)" } },
  left: { path: "M6 0L12 6L6 12", style: { left: "calc(100% - 6px)" } },
  right: { path: "M6 0L0 6L6 12", style: { right: "calc(100% - 6px)" } },
};

export function TooltipArrow({ side, ref }: { side: TooltipSide; ref: Ref<SVGSVGElement> }) {
  const { path, style } = ARROW[side];
  return (
    <svg
      ref={ref}
      aria-hidden="true"
      width="12"
      height="12"
      viewBox="0 0 12 12"
      className="pointer-events-none absolute overflow-visible"
      style={style}
    >
      <path d={`${path}Z`} style={{ fill: "var(--tooltip-arrow-fill, transparent)" }} />
      <path
        d={path}
        fill="none"
        strokeWidth="1"
        style={{ stroke: "var(--tooltip-arrow-stroke, transparent)" }}
      />
    </svg>
  );
}
components/motion/tooltip/use-position.ts
"use client";

import {
  arrow as arrowMiddleware,
  autoUpdate,
  computePosition,
  flip,
  hide,
  offset,
  type Placement,
  shift,
  type VirtualElement,
} from "@floating-ui/dom";
import { type RefObject, useCallback, useLayoutEffect, useRef } from "react";

export type TooltipSide = "top" | "right" | "bottom" | "left";
export type TooltipAlign = "start" | "center" | "end";
export type TooltipPoint = { x: number; y: number };
export type TooltipAnchor = HTMLElement | SVGElement;

const ORIGIN: Record<TooltipSide, string> = {
  top: "center bottom",
  bottom: "center top",
  left: "right center",
  right: "left center",
};

/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */
export function useTooltipPosition({
  open,
  anchor,
  floatingRef,
  arrowRef,
  anchorPoint,
  followCursor,
  side,
  align,
  sideOffset,
  onDismiss,
  onPosition,
  pointer,
}: {
  open: boolean;
  anchor: TooltipAnchor | null;
  floatingRef: RefObject<HTMLSpanElement | null>;
  arrowRef?: RefObject<SVGSVGElement | null>;
  anchorPoint?: TooltipPoint;
  followCursor: boolean;
  side: TooltipSide;
  align: TooltipAlign;
  sideOffset?: number;
  onDismiss: () => void;
  onPosition: (side: TooltipSide) => void;
  pointer: ReturnType<typeof useTooltipPointer>;
}) {
  const { cursor, onMove } = pointer;
  const preferred: Placement = align === "center" ? side : `${side}-${align}`;
  const cursorSide = useRef<Placement | null>(null);
  useLayoutEffect(() => {
    cursorSide.current = open ? preferred : null;
  }, [open, preferred]);
  const frame = useRef<number | null>(null);
  const version = useRef(0);
  const update = useRef<() => void>(() => {});
  const schedule = useCallback(() => {
    // Invalidate older async calculations as soon as new geometry is requested.
    version.current++;
    if (frame.current !== null) return;
    frame.current = requestAnimationFrame(() => {
      frame.current = null;
      update.current();
    });
  }, []);
  useLayoutEffect(() => {
    onMove.current = schedule;
    return () => {
      onMove.current = null;
    };
  }, [onMove, schedule]);
  const pointX = anchorPoint?.x;
  const pointY = anchorPoint?.y;

  // Latest committed inputs are read without recreating observers for every period/content update.
  useLayoutEffect(() => {
    update.current = () => {
      const floating = floatingRef.current;
      if (!open || !anchor || !floating) return;
      const revision = ++version.current;
      const currentCursor = followCursor ? cursor.current : null;
      const virtual = Boolean(currentCursor) || pointX !== undefined || pointY !== undefined;
      const reference: Element | VirtualElement = virtual
        ? {
            contextElement: anchor,
            getBoundingClientRect: () => {
              const rect = anchor.getBoundingClientRect();
              const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);
              const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);
              return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };
            },
          }
        : anchor;
      const arrowElement = arrowRef?.current ?? null;
      void computePosition(reference, floating, {
        strategy: "fixed",
        placement: currentCursor ? (cursorSide.current ?? preferred) : preferred,
        middleware: [
          offset(sideOffset ?? (currentCursor ? 12 : 8)),
          flip({ padding: 8 }),
          shift({ padding: 8 }),
          ...(arrowElement ? [arrowMiddleware({ element: arrowElement, padding: 10 })] : []),
          // A label left floating over whatever scrolled into its trigger's place
          // describes nothing there. Points inside a chart move with the chart.
          ...(virtual ? [] : [hide({ strategy: "referenceHidden" })]),
        ],
      }).then(({ x, y, placement, middlewareData }) => {
        if (version.current !== revision || !floating.isConnected) return;
        // Hold the chosen side for this hover session. Crossing a flip threshold
        // repeatedly must not bounce the surface above and below the pointer.
        if (currentCursor) cursorSide.current = placement;
        else cursorSide.current = null;
        const placedSide = placement.split("-")[0] as TooltipSide;
        const dpr = window.devicePixelRatio || 1;
        floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;
        floating.style.setProperty("--tooltip-origin", ORIGIN[placedSide]);
        // A box with no size has no clipped state to report, only a missing layout.
        const box = anchor.getBoundingClientRect();
        const clipped =
          Boolean(middlewareData.hide?.referenceHidden) && (box.width > 0 || box.height > 0);
        floating.style.visibility = clipped ? "hidden" : "visible";
        floating.dataset.placement = placement;
        const surface = arrowElement?.parentElement;
        if (arrowElement && surface) {
          // Measured against the positioner, drawn inside the surface's border.
          const { x: ax, y: ay } = middlewareData.arrow ?? {};
          if (ax != null) arrowElement.style.left = `${ax - surface.clientLeft}px`;
          if (ay != null) arrowElement.style.top = `${ay - surface.clientTop}px`;
          // Paint with whatever the surface resolved to, so a restyled tooltip
          // keeps a matching arrow without a second set of classes.
          const style = getComputedStyle(surface);
          arrowElement.style.setProperty("--tooltip-arrow-fill", style.backgroundColor);
          arrowElement.style.setProperty("--tooltip-arrow-stroke", style.borderTopColor);
        }
        onPosition(placedSide);
      });
    };
    if (open) schedule();
  }, [
    open,
    anchor,
    floatingRef,
    arrowRef,
    followCursor,
    pointX,
    pointY,
    preferred,
    sideOffset,
    schedule,
    onPosition,
    cursor,
  ]);

  useLayoutEffect(() => {
    const floating = floatingRef.current;
    if (!open || !anchor || !floating) return;
    const stop = autoUpdate(anchor, floating, schedule);
    const onScroll = () => {
      // A stationary pointer no longer describes the same chart point after scrolling.
      if (followCursor && cursor.current) onDismiss();
    };
    window.addEventListener("scroll", onScroll, true);
    return () => {
      stop();
      window.removeEventListener("scroll", onScroll, true);
      version.current++;
      if (frame.current !== null) cancelAnimationFrame(frame.current);
      frame.current = null;
    };
  }, [open, anchor, floatingRef, followCursor, onDismiss, schedule, cursor]);

  useLayoutEffect(
    () => () => {
      version.current++;
      if (frame.current !== null) cancelAnimationFrame(frame.current);
    },
    [],
  );
}

/** Pointer lifetime belongs to the anchor, including the opening delay. */
export function useTooltipPointer(anchor: TooltipAnchor | null, followCursor: boolean) {
  const cursor = useRef<TooltipPoint | null>(null);
  const onMove = useRef<(() => void) | null>(null);
  const pointerFocus = useRef(false);
  useLayoutEffect(() => {
    if (!anchor || !followCursor) return;
    const point = (event: PointerEvent) => {
      if (event.type === "pointermove" && event.pointerType === "touch" && !event.buttons) return;
      cursor.current = { x: event.clientX, y: event.clientY };
      if (event.type === "pointerdown") pointerFocus.current = true;
      onMove.current?.();
    };
    const keyboard = () => {
      cursor.current = null;
      pointerFocus.current = false;
      onMove.current?.();
    };
    const focus = () => {
      if (!pointerFocus.current) cursor.current = null;
      pointerFocus.current = false;
      onMove.current?.();
    };
    const leave = () => {
      cursor.current = null;
    };
    anchor.addEventListener("pointerenter", point as EventListener, { passive: true });
    anchor.addEventListener("pointermove", point as EventListener, { passive: true });
    anchor.addEventListener("pointerdown", point as EventListener, { passive: true });
    anchor.addEventListener("pointerleave", leave);
    anchor.addEventListener("pointercancel", leave);
    anchor.addEventListener("keydown", keyboard);
    anchor.addEventListener("focusin", focus);
    return () => {
      cursor.current = null;
      anchor.removeEventListener("pointerenter", point as EventListener);
      anchor.removeEventListener("pointermove", point as EventListener);
      anchor.removeEventListener("pointerdown", point as EventListener);
      anchor.removeEventListener("pointerleave", leave);
      anchor.removeEventListener("pointercancel", leave);
      anchor.removeEventListener("keydown", keyboard);
      anchor.removeEventListener("focusin", focus);
    };
  }, [anchor, followCursor]);

  return { cursor, onMove };
}
components/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>
  );
}
lib/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]);
}
lib/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";
      },
    }),
    [],
  );
}
lib/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;
      },
    }),
    [],
  );
}
lib/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;

More from beui

Browse all components in this library →