{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"candlestick-chart","type":"registry:component","title":"Candlestick Chart","description":"Live OHLC candles with volume, a last-price line, and a crosshair readout.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/candlestick-chart.tsx","type":"registry:component","target":"@components/charts/candlestick-chart.tsx","content":"\"use client\";\n// beui.dev/charts/candlestick-chart\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type CandlestickChartProps,\n  CandlestickContext,\n  useCandlestickModel,\n} from \"./candlestick-chart/context\";\nimport { CandlestickChartLegend } from \"./candlestick-chart/legend\";\nimport { CandlestickChartPlot } from \"./candlestick-chart/plot\";\n\n/**\n * OHLC candles with volume, a last-price line, and a crosshair readout.\n *\n * ```tsx\n * <CandlestickChart candles={candles} lastPrice={last} formatPrice={usd}>\n *   <CandlestickChartLegend />\n *   <CandlestickChartPlot height={360} />\n * </CandlestickChart>\n * ```\n */\nexport function CandlestickChart({\n  className,\n  children,\n  ...props\n}: CandlestickChartProps) {\n  const model = useCandlestickModel(props);\n  return (\n    <CandlestickContext.Provider value={model}>\n      <div className={cn(\"w-full\", className)}>\n        {children ?? (\n          <div className=\"grid gap-3\">\n            <CandlestickChartLegend />\n            <CandlestickChartPlot />\n          </div>\n        )}\n      </div>\n    </CandlestickContext.Provider>\n  );\n}\n\nexport {\n  type Candle,\n  type CandlestickChartProps,\n  useCandlestickChart,\n} from \"./candlestick-chart/context\";\nexport {\n  CandlestickChartLegend,\n  type CandlestickChartLegendProps,\n} from \"./candlestick-chart/legend\";\nexport {\n  CandlestickChartPlot,\n  type CandlestickChartPlotProps,\n} from \"./candlestick-chart/plot\";\n"},{"path":"components/charts/candlestick-chart/context.tsx","type":"registry:component","target":"@components/charts/candlestick-chart/context.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type Candle = {\n  /** Stable id, such as the open timestamp. */\n  id: string;\n  /** Axis and readout label, such as \"14:05\". */\n  label: string;\n  open: number;\n  high: number;\n  low: number;\n  close: number;\n  volume: number;\n};\n\nexport interface CandlestickChartProps {\n  candles: readonly Candle[];\n  /** Draws a dashed line and tag at this price, usually the last trade. */\n  lastPrice?: number;\n  /**\n   * Controlled crosshair candle, by `id`, so it stays on the same candle as\n   * new ones arrive. Pair with `onActiveIdChange`.\n   */\n  activeId?: string | null;\n  defaultActiveId?: string | null;\n  onActiveIdChange?: (id: string | null) => void;\n  formatPrice?: (value: number) => string;\n  formatVolume?: (value: number) => string;\n  /** Accessible name for the chart. */\n  label?: string;\n  className?: string;\n  children?: ReactNode;\n}\n\nconst defaultPrice = (value: number) =>\n  value.toLocaleString(\"en-US\", { maximumFractionDigits: 2 });\nconst defaultVolume = (value: number) =>\n  new Intl.NumberFormat(\"en-US\", {\n    notation: \"compact\",\n    maximumFractionDigits: 1,\n  }).format(value);\n\nexport function useCandlestickModel({\n  candles,\n  lastPrice,\n  activeId,\n  defaultActiveId = null,\n  onActiveIdChange,\n  formatPrice = defaultPrice,\n  formatVolume = defaultVolume,\n  label = \"Candlestick chart\",\n}: CandlestickChartProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [internalActive, setInternalActive] = useState(defaultActiveId);\n  const rawActive = activeId === undefined ? internalActive : activeId;\n  // Resolve the crosshair during render by id; a candle that scrolled out of\n  // the window clears it rather than landing on a neighbour.\n  const index = rawActive === null ? -1 : candles.findIndex((candle) => candle.id === rawActive);\n  const active = index >= 0 ? index : null;\n\n  // Stable setter that reads the latest candles and callback from a ref.\n  const latest = useRef({ candles, controlled: activeId !== undefined, onActiveIdChange });\n  useLayoutEffect(() => {\n    latest.current = { candles, controlled: activeId !== undefined, onActiveIdChange };\n  });\n  const setActive = useCallback((next: number | null) => {\n    const id = next === null ? null : (latest.current.candles[next]?.id ?? null);\n    if (!latest.current.controlled) setInternalActive(id);\n    latest.current.onActiveIdChange?.(id);\n  }, []);\n\n  const scale = useMemo(() => {\n    const lows = candles.map((candle) => candle.low);\n    const highs = candles.map((candle) => candle.high);\n    if (lastPrice !== undefined) {\n      lows.push(lastPrice);\n      highs.push(lastPrice);\n    }\n    const min = lows.length ? Math.min(...lows) : 0;\n    const max = highs.length ? Math.max(...highs) : 1;\n    const pad = Math.max((max - min) * 0.08, max * 0.0005, 0.0001);\n    const volumeMax = Math.max(1, ...candles.map((candle) => candle.volume));\n    return { min: min - pad, max: max + pad, volumeMax };\n  }, [candles, lastPrice]);\n\n  return {\n    candles,\n    lastPrice,\n    active,\n    setActive,\n    scale,\n    formatPrice,\n    formatVolume,\n    label,\n    reduce,\n  };\n}\n\nexport type CandlestickModel = ReturnType<typeof useCandlestickModel>;\n\nexport const CandlestickContext = createContext<CandlestickModel | null>(null);\n\nexport function useCandlestickChart() {\n  const model = useContext(CandlestickContext);\n  if (!model)\n    throw new Error(\n      \"CandlestickChart parts must be used inside <CandlestickChart>\",\n    );\n  return model;\n}\n"},{"path":"components/charts/candlestick-chart/legend.tsx","type":"registry:component","target":"@components/charts/candlestick-chart/legend.tsx","content":"\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { type Candle, useCandlestickChart } from \"./context\";\n\nexport type CandlestickChartLegendProps = {\n  /** Custom readout. Receives the crosshair candle, or the latest one. */\n  children?: (candle: Candle, change: number) => ReactNode;\n  className?: string;\n};\n\n/** OHLC and volume readout that follows the crosshair. */\nexport function CandlestickChartLegend({\n  children,\n  className,\n}: CandlestickChartLegendProps) {\n  const { candles, active, formatPrice, formatVolume } = useCandlestickChart();\n  const candle = active !== null ? candles[active] : candles.at(-1);\n  if (!candle) return null;\n  const change = candle.open\n    ? ((candle.close - candle.open) / candle.open) * 100\n    : 0;\n  if (children) return <>{children(candle, change)}</>;\n  const tone =\n    change >= 0\n      ? \"text-emerald-600 dark:text-emerald-400\"\n      : \"text-rose-600 dark:text-rose-400\";\n  const items: [string, string][] = [\n    [\"O\", formatPrice(candle.open)],\n    [\"H\", formatPrice(candle.high)],\n    [\"L\", formatPrice(candle.low)],\n    [\"C\", formatPrice(candle.close)],\n    [\"Vol\", formatVolume(candle.volume)],\n  ];\n  return (\n    <dl\n      className={cn(\n        \"flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs tabular-nums\",\n        className,\n      )}\n    >\n      <dt className=\"sr-only\">Candle</dt>\n      <dd className=\"text-muted-foreground\">{candle.label}</dd>\n      {items.map(([term, value]) => (\n        <div key={term} className=\"flex gap-1\">\n          <dt className=\"text-muted-foreground\">{term}</dt>\n          <dd className={term === \"Vol\" ? undefined : tone}>{value}</dd>\n        </div>\n      ))}\n      <dd className={tone}>\n        {change >= 0 ? \"+\" : \"−\"}\n        {Math.abs(change).toFixed(2)}%\n      </dd>\n    </dl>\n  );\n}\n"},{"path":"components/charts/candlestick-chart/plot.tsx","type":"registry:component","target":"@components/charts/candlestick-chart/plot.tsx","content":"\"use client\";\n\n/* Candles fade up 12ms apart on first draw. The live candle's body, wick, and\n * volume bar spring to new values as trades print, and the last-price line\n * follows. Pointer or arrow keys move a crosshair with a price tag on the\n * axis. Every moving shape is drawn at unit height and placed with\n * translateY and scaleY, so updates animate transforms only. Reduced motion:\n * no fade or spring; values update in place. */\n\nimport { motion, type Transition } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useCandlestickChart } from \"./context\";\n\n/** Right gutter reserved for price labels, in px. */\nconst AXIS = 64;\nconst VOLUME_SHARE = 0.18;\nconst GAP = 8;\n/** Scale from the shape's own top edge rather than its centre. */\nconst TOP_ORIGIN = { originX: 0, originY: 0 } as const;\n\nexport type CandlestickChartPlotProps = {\n  /** Plot height in px. */\n  height?: number;\n  className?: string;\n};\n\nexport function CandlestickChartPlot({\n  height = 320,\n  className,\n}: CandlestickChartPlotProps) {\n  const { candles, lastPrice, active, setActive, scale, formatPrice, label, reduce } =\n    useCandlestickChart();\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [pointerY, setPointerY] = useState<number | null>(null);\n\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node) return;\n    const update = () => setWidth(node.clientWidth);\n    update();\n    const observer = new ResizeObserver(update);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const plotWidth = Math.max(0, width - AXIS);\n  const priceHeight = height * (1 - VOLUME_SHARE) - GAP;\n  const slot = candles.length ? plotWidth / candles.length : 0;\n  const body = Math.max(1, slot * 0.62);\n  const y = (price: number) => ((scale.max - price) / (scale.max - scale.min || 1)) * priceHeight;\n  const priceAt = (py: number) => scale.max - (py / priceHeight) * (scale.max - scale.min);\n  const ticks = Array.from(\n    { length: 5 },\n    (_, i) => scale.max - ((scale.max - scale.min) * (i + 0.5)) / 5,\n  );\n  const spring: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const x = event.clientX - rect.left;\n    const py = event.clientY - rect.top;\n    if (x > plotWidth || !slot) return;\n    setActive(Math.min(candles.length - 1, Math.max(0, Math.floor(x / slot))));\n    setPointerY(py <= priceHeight ? py : null);\n  };\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    const current = active ?? candles.length - 1;\n    let next: number | null = current;\n    if (event.key === \"ArrowLeft\") next = Math.max(0, current - 1);\n    else if (event.key === \"ArrowRight\") next = Math.min(candles.length - 1, current + 1);\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = candles.length - 1;\n    else if (event.key === \"Escape\") next = null;\n    else return;\n    event.preventDefault();\n    setPointerY(null);\n    setActive(next);\n  };\n\n  const focus = active !== null ? candles[active] : candles.at(-1);\n  const crossX = active !== null ? active * slot + slot / 2 : null;\n\n  return (\n    <div\n      ref={ref}\n      role=\"slider\"\n      tabIndex={0}\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={Math.max(0, candles.length - 1)}\n      aria-valuenow={active ?? Math.max(0, candles.length - 1)}\n      aria-valuetext={\n        focus\n          ? `${focus.label}: open ${formatPrice(focus.open)}, high ${formatPrice(focus.high)}, low ${formatPrice(focus.low)}, close ${formatPrice(focus.close)}`\n          : undefined\n      }\n      onPointerMove={onPointerMove}\n      onPointerLeave={() => {\n        setActive(null);\n        setPointerY(null);\n      }}\n      onKeyDown={onKeyDown}\n      onBlur={() => setActive(null)}\n      className={cn(\n        \"relative w-full cursor-crosshair touch-pan-y select-none rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      style={{ height }}\n    >\n      {width > 0 ? (\n        <svg\n          width={width}\n          height={height}\n          className=\"absolute inset-0 overflow-visible\"\n          aria-hidden=\"true\"\n        >\n          {ticks.map((tick) => (\n            <line\n              key={tick}\n              x1={0}\n              x2={plotWidth}\n              y1={y(tick)}\n              y2={y(tick)}\n              stroke=\"var(--border)\"\n              strokeDasharray=\"2 6\"\n            />\n          ))}\n          {candles.map((candle, index) => {\n            const up = candle.close >= candle.open;\n            const cx = index * slot + slot / 2;\n            const top = y(Math.max(candle.open, candle.close));\n            const bodyHeight = Math.max(1, Math.abs(y(candle.open) - y(candle.close)));\n            const wickTop = y(candle.high);\n            const wickHeight = Math.max(1, y(candle.low) - wickTop);\n            const volumeHeight = Math.max(\n              0.5,\n              (candle.volume / scale.volumeMax) * height * VOLUME_SHARE,\n            );\n            const dimmed = active !== null && active !== index;\n            return (\n              <motion.g\n                key={candle.id}\n                className={\n                  up ? \"fill-emerald-500 stroke-emerald-500\" : \"fill-rose-500 stroke-rose-500\"\n                }\n                initial={reduce ? false : { opacity: 0 }}\n                animate={{ opacity: dimmed ? 0.45 : 1 }}\n                transition={{\n                  duration: 0.3,\n                  ease: EASE_OUT,\n                  delay: reduce ? 0 : Math.min(index * 0.012, 0.6),\n                }}\n              >\n                <motion.line\n                  x1={cx}\n                  x2={cx}\n                  y1={0}\n                  y2={1}\n                  vectorEffect=\"non-scaling-stroke\"\n                  strokeWidth={1}\n                  style={TOP_ORIGIN}\n                  initial={false}\n                  animate={{ y: wickTop, scaleY: wickHeight }}\n                  transition={spring}\n                />\n                <motion.rect\n                  x={cx - body / 2}\n                  y={0}\n                  width={body}\n                  height={1}\n                  strokeWidth={0}\n                  style={TOP_ORIGIN}\n                  initial={false}\n                  animate={{ y: top, scaleY: bodyHeight }}\n                  transition={spring}\n                />\n                <motion.rect\n                  x={cx - body / 2}\n                  y={0}\n                  width={body}\n                  height={1}\n                  strokeWidth={0}\n                  opacity={0.28}\n                  style={TOP_ORIGIN}\n                  initial={false}\n                  animate={{ y: height - volumeHeight, scaleY: volumeHeight }}\n                  transition={spring}\n                />\n              </motion.g>\n            );\n          })}\n          {lastPrice !== undefined ? (\n            <motion.line\n              x1={0}\n              x2={plotWidth}\n              y1={0}\n              y2={0}\n              initial={false}\n              animate={{ y: y(lastPrice) }}\n              transition={spring}\n              stroke=\"var(--foreground)\"\n              strokeOpacity={0.45}\n              strokeDasharray=\"3 4\"\n            />\n          ) : null}\n          {crossX !== null ? (\n            <line\n              x1={crossX}\n              x2={crossX}\n              y1={0}\n              y2={height}\n              stroke=\"var(--foreground)\"\n              strokeOpacity={0.3}\n            />\n          ) : null}\n          {pointerY !== null ? (\n            <line\n              x1={0}\n              x2={plotWidth}\n              y1={pointerY}\n              y2={pointerY}\n              stroke=\"var(--foreground)\"\n              strokeOpacity={0.3}\n            />\n          ) : null}\n        </svg>\n      ) : null}\n\n      {width > 0\n        ? ticks.map((tick) => (\n            <span\n              key={tick}\n              className=\"pointer-events-none absolute right-0 -translate-y-1/2 pr-1 font-mono text-xs text-muted-foreground tabular-nums\"\n              style={{ top: y(tick) }}\n            >\n              {formatPrice(tick)}\n            </span>\n          ))\n        : null}\n      {width > 0 && lastPrice !== undefined ? (\n        <motion.div\n          className=\"pointer-events-none absolute top-0 right-0\"\n          initial={false}\n          animate={{ y: y(lastPrice) }}\n          transition={spring}\n        >\n          <span className=\"block -translate-y-1/2 rounded-md bg-foreground px-1.5 py-0.5 font-mono text-xs text-background tabular-nums\">\n            {formatPrice(lastPrice)}\n          </span>\n        </motion.div>\n      ) : null}\n      {width > 0 && pointerY !== null ? (\n        <span\n          className=\"pointer-events-none absolute right-0 -translate-y-1/2 rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground tabular-nums\"\n          style={{ top: pointerY }}\n        >\n          {formatPrice(priceAt(pointerY))}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"}]}