Skip to content

Candlestick Chart

Timeframe tabs, a crosshair with OHLC tooltip and smooth transitions.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/price-chart.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/price-chart/price-chart.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent } from "react";
    import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { TrendingDown, TrendingUp } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { spring } from "@/lib/motion";
    
    export interface Candle {
      /** Open time in ms since epoch. */
      time: number;
      open: number;
      high: number;
      low: number;
      close: number;
      volume?: number;
    }
    
    export interface PriceChartProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Candles for the selected timeframe, oldest first. */
      data: Candle[];
      /** Pair label in the header. @default "ETH/USD" */
      pair?: string;
      /** Timeframe tabs. @default ["1H", "1D", "1W", "1M"] */
      timeframes?: string[];
      /** Controlled timeframe. */
      timeframe?: string;
      /** Initial timeframe when uncontrolled. @default the second timeframe */
      defaultTimeframe?: string;
      /** Called when a timeframe tab is picked. Load that timeframe's data here. */
      onTimeframeChange?: (timeframe: string) => void;
      /** Formats prices on the axis, tooltip and header. @default USD with 2 decimals */
      formatPrice?: (price: number) => string;
      /** Chart height in pixels, excluding the header. @default 280 */
      height?: number;
      /** Show a skeleton while data loads. @default false */
      loading?: boolean;
      /** Time zone for time labels. Fixed by default so server and client agree. @default "UTC" */
      timeZone?: string;
    }
    
    const PAD = { top: 12, right: 76, bottom: 24, left: 4 };
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 2 });
    const UP = "var(--success)";
    const DOWN = "var(--destructive)";
    
    function niceTicks(min: number, max: number, count = 4) {
      const span = max - min || 1;
      const step = 10 ** Math.floor(Math.log10(span / count));
      const nice = [1, 2, 2.5, 5, 10].map((m) => m * step).find((s) => span / s <= count) ?? step * 10;
      const ticks: number[] = [];
      for (let v = Math.ceil(min / nice) * nice; v <= max; v += nice) ticks.push(v);
      return ticks;
    }
    
    export function PriceChart({
      data,
      pair = "ETH/USD",
      timeframes = ["1H", "1D", "1W", "1M"],
      timeframe,
      defaultTimeframe,
      onTimeframeChange,
      formatPrice = (n) => usd.format(n),
      height = 280,
      loading = false,
      timeZone = "UTC",
      className,
      ...props
    }: PriceChartProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const id = useId();
      const [internalTf, setInternalTf] = useState(defaultTimeframe ?? timeframes[1] ?? timeframes[0]);
      const tf = timeframe ?? internalTf;
      const [width, setWidth] = useState(0);
      const [active, setActive] = useState<number | null>(null);
      const box = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        const el = box.current;
        if (!el) return;
        const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
        observer.observe(el);
        return () => observer.disconnect();
      }, []);
    
      const choose = (next: string) => {
        if (timeframe === undefined) setInternalTf(next);
        onTimeframeChange?.(next);
        setActive(null);
      };
    
      const n = data.length;
      const first = data[0];
      const last = data[n - 1];
      const change = first && last ? (last.close - first.open) / first.open : 0;
      const lows = data.map((d) => d.low);
      const highs = data.map((d) => d.high);
      const min = n ? Math.min(...lows) : 0;
      const max = n ? Math.max(...highs) : 1;
      const pad = (max - min) * 0.08 || 1;
      const yMin = min - pad;
      const yMax = max + pad;
      const plotW = Math.max(0, width - PAD.left - PAD.right);
      const priceH = (height - PAD.top - PAD.bottom) * 0.82;
      const volTop = PAD.top + priceH + 6;
      const volH = height - PAD.bottom - volTop;
      const step = n ? plotW / n : 0;
      const x = (i: number) => PAD.left + step * (i + 0.5);
      const y = (v: number) => PAD.top + ((yMax - v) / (yMax - yMin)) * priceH;
      const maxVol = Math.max(1, ...data.map((d) => d.volume ?? 0));
      const ticks = niceTicks(min, max);
      const bodyW = Math.max(1, Math.min(14, step * 0.62));
    
      const longRange = n > 1 && last.time - first.time > 2 * 86_400_000;
      const timeLabel = new Intl.DateTimeFormat("en-US", longRange ? { month: "short", day: "numeric", timeZone } : { hour: "2-digit", minute: "2-digit", hour12: false, timeZone });
      const fullTime = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone });
      const focus = active !== null ? data[active] : null;
    
      function onPointerMove(event: PointerEvent<SVGRectElement>) {
        const rect = event.currentTarget.getBoundingClientRect();
        const i = Math.floor(((event.clientX - rect.left) / rect.width) * n);
        setActive(Math.min(n - 1, Math.max(0, i)));
      }
    
      function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
        if (!n) return;
        const keys: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1 };
        if (event.key in keys) {
          event.preventDefault();
          setActive((a) => Math.min(n - 1, Math.max(0, (a ?? n - 1) + keys[event.key])));
        } else if (event.key === "Home" || event.key === "End") {
          event.preventDefault();
          setActive(event.key === "Home" ? 0 : n - 1);
        } else if (event.key === "Escape") {
          setActive(null);
        }
      }
    
      const up = change >= 0;
      const describe = (c: Candle) =>
        `${fullTime.format(c.time)}: open ${formatPrice(c.open)}, high ${formatPrice(c.high)}, low ${formatPrice(c.low)}, close ${formatPrice(c.close)}`;
    
      return (
        <div className={cn("w-full max-w-2xl rounded-2xl border border-border bg-background p-4 shadow-sm", className)} {...props}>
          <div className="flex flex-wrap items-start justify-between gap-3">
            <div>
              <p className="text-[13px] text-muted-foreground">{pair}</p>
              <p className="mt-0.5 flex items-baseline gap-2">
                <span className="font-mono text-2xl font-semibold tracking-tight text-foreground tabular-nums">
                  {loading || !last ? "—" : formatPrice((focus ?? last).close)}
                </span>
                {!loading && last ? (
                  <span
                    className={cn(
                      "inline-flex items-center gap-0.5 font-mono text-xs tabular-nums",
                      up ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]",
                    )}
                  >
                    {up ? <TrendingUp className="size-3" aria-hidden="true" /> : <TrendingDown className="size-3" aria-hidden="true" />}
                    {up ? "+" : ""}
                    {(change * 100).toFixed(2)}%<span className="sr-only"> over {tf}</span>
                  </span>
                ) : null}
              </p>
            </div>
            <LayoutGroup id={id}>
              <div role="radiogroup" aria-label="Timeframe" className="flex gap-0.5 rounded-lg bg-muted p-0.5">
                {timeframes.map((option) => {
                  const selected = option === tf;
                  return (
                    <button
                      key={option}
                      type="button"
                      role="radio"
                      aria-checked={selected}
                      onClick={() => choose(option)}
                      className={cn(
                        "relative h-8 min-w-10 cursor-pointer rounded-md px-2 font-mono text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring",
                        selected ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                      )}
                    >
                      {selected ? (
                        <motion.span
                          layoutId="timeframe"
                          transition={reduceMotion ? { duration: 0 } : spring.snappy}
                          className="absolute inset-0 rounded-md bg-background shadow-xs"
                        />
                      ) : null}
                      <span className="relative">{option}</span>
                    </button>
                  );
                })}
              </div>
            </LayoutGroup>
          </div>
    
          <div
            ref={box}
            tabIndex={0}
            role="group"
            aria-roledescription="chart"
            aria-label={`${pair} candlestick chart, ${tf}. Use arrow keys to inspect candles.`}
            onKeyDown={onKeyDown}
            onFocus={() => setActive((a) => a ?? (n ? n - 1 : null))}
            onBlur={() => setActive(null)}
            className="relative mt-4 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
            style={{ height }}
          >
            {loading || width === 0 ? (
              <div className={cn("absolute inset-0 rounded-lg bg-muted/60", loading && "animate-pulse motion-reduce:animate-none")} aria-hidden="true" />
            ) : n === 0 ? (
              <p className="absolute inset-0 grid place-items-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
                No price data for this timeframe.
              </p>
            ) : (
              <svg width={width} height={height} className="block overflow-visible" aria-hidden="true">
                {ticks.map((t) => (
                  <g key={t}>
                    <line x1={PAD.left} x2={PAD.left + plotW} y1={y(t)} y2={y(t)} stroke="var(--border)" strokeDasharray="2 4" />
                    <text x={PAD.left + plotW + 8} y={y(t)} dy="0.35em" fontSize="11" fill="var(--muted-foreground)" className="font-mono tabular-nums">
                      {formatPrice(t)}
                    </text>
                  </g>
                ))}
                <AnimatePresence initial={false} mode="popLayout">
                  <motion.g
                    key={`${tf}:${n}:${first?.time}`}
                    initial={reduceMotion ? { opacity: 0 } : { opacity: 0, clipPath: "inset(0% 100% 0% 0%)" }}
                    animate={reduceMotion ? { opacity: 1 } : { opacity: 1, clipPath: "inset(0% 0% 0% 0%)" }}
                    exit={{ opacity: 0, transition: { duration: 0.15 } }}
                    transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
                  >
                    {data.map((c, i) => {
                      const rising = c.close >= c.open;
                      const color = rising ? UP : DOWN;
                      const top = y(Math.max(c.open, c.close));
                      const bodyH = Math.max(1, Math.abs(y(c.open) - y(c.close)));
                      const vh = ((c.volume ?? 0) / maxVol) * volH;
                      return (
                        <g key={c.time} opacity={active !== null && active !== i ? 0.55 : 1}>
                          <line x1={x(i)} x2={x(i)} y1={y(c.high)} y2={y(c.low)} stroke={color} strokeWidth={1} />
                          <rect x={x(i) - bodyW / 2} y={top} width={bodyW} height={bodyH} rx={Math.min(1.5, bodyW / 4)} fill={color} />
                          {c.volume !== undefined ? (
                            <rect x={x(i) - bodyW / 2} y={volTop + volH - vh} width={bodyW} height={vh} rx={1} fill={color} opacity={0.22} />
                          ) : null}
                        </g>
                      );
                    })}
                  </motion.g>
                </AnimatePresence>
                {last ? (
                  <g>
                    <line x1={PAD.left} x2={PAD.left + plotW} y1={y(last.close)} y2={y(last.close)} stroke={up ? UP : DOWN} strokeDasharray="3 3" opacity={0.7} />
                    <rect x={PAD.left + plotW + 2} y={y(last.close) - 9} width={PAD.right - 4} height={18} rx={4} fill={up ? UP : DOWN} />
                    <text x={PAD.left + plotW + 8} y={y(last.close)} dy="0.35em" fontSize="11" fill="#fff" className="font-mono tabular-nums">
                      {formatPrice(last.close)}
                    </text>
                  </g>
                ) : null}
                {n > 1
                  ? [0, Math.floor((n - 1) / 2), n - 1].map((i) => (
                      <text key={i} x={x(i)} y={height - 6} textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"} fontSize="11" fill="var(--muted-foreground)">
                        {timeLabel.format(data[i].time)}
                      </text>
                    ))
                  : null}
                {focus && active !== null ? (
                  <g pointerEvents="none">
                    <line x1={x(active)} x2={x(active)} y1={PAD.top} y2={height - PAD.bottom} stroke="var(--foreground)" strokeOpacity={0.35} strokeDasharray="3 3" />
                    <line x1={PAD.left} x2={PAD.left + plotW} y1={y(focus.close)} y2={y(focus.close)} stroke="var(--foreground)" strokeOpacity={0.35} strokeDasharray="3 3" />
                    <rect x={PAD.left + plotW + 2} y={y(focus.close) - 9} width={PAD.right - 4} height={18} rx={4} fill="var(--foreground)" />
                    <text x={PAD.left + plotW + 8} y={y(focus.close)} dy="0.35em" fontSize="11" fill="var(--background)" className="font-mono tabular-nums">
                      {formatPrice(focus.close)}
                    </text>
                  </g>
                ) : null}
                <rect
                  x={PAD.left}
                  y={PAD.top}
                  width={plotW}
                  height={height - PAD.top - PAD.bottom}
                  fill="transparent"
                  onPointerMove={onPointerMove}
                  onPointerLeave={() => setActive(null)}
                />
              </svg>
            )}
            {focus && active !== null && width > 0 ? (
              <div
                className="pointer-events-none absolute top-2 z-10 w-44 rounded-lg border border-border bg-popover/95 p-2.5 text-xs shadow-md backdrop-blur"
                style={{ left: Math.min(Math.max(0, x(active) + (x(active) > width / 2 ? -188 : 12)), width - 180) }}
              >
                <p className="font-medium text-foreground">{fullTime.format(focus.time)}</p>
                <dl className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 font-mono tabular-nums">
                  {(["open", "high", "low", "close"] as const).map((k) => (
                    <div key={k} className="contents">
                      <dt className="text-muted-foreground capitalize">{k}</dt>
                      <dd className="text-right text-foreground">{formatPrice(focus[k])}</dd>
                    </div>
                  ))}
                </dl>
              </div>
            ) : null}
            <span className="sr-only" role="status" aria-live="polite">
              {focus ? describe(focus) : ""}
            </span>
          </div>
    
          <table className="sr-only">
            <caption>
              {pair} prices, {tf}
            </caption>
            <thead>
              <tr>
                <th scope="col">Time</th>
                <th scope="col">Open</th>
                <th scope="col">High</th>
                <th scope="col">Low</th>
                <th scope="col">Close</th>
              </tr>
            </thead>
            <tbody>
              {data.map((c) => (
                <tr key={c.time}>
                  <th scope="row">{fullTime.format(c.time)}</th>
                  <td>{formatPrice(c.open)}</td>
                  <td>{formatPrice(c.high)}</td>
                  <td>{formatPrice(c.low)}</td>
                  <td>{formatPrice(c.close)}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      );
    }

Usage

import { PriceChart } from "@/components/ui/price-chart/price-chart";

const [timeframe, setTimeframe] = useState("1D");

<PriceChart
  pair="ETH/USD"
  data={candles[timeframe]} // { time, open, high, low, close, volume? }[]
  timeframe={timeframe}
  onTimeframeChange={setTimeframe}
/>

API reference

PropTypeDefaultDescription
data*Candle[]–Candles for the selected timeframe, oldest first.
pairstring"ETH/USD"Pair label in the header.
timeframesstring[]["1H", "1D", "1W", "1M"]Timeframe tabs.
timeframestring–Controlled timeframe.
defaultTimeframestringthe second timeframeInitial timeframe when uncontrolled.
onTimeframeChange(timeframe: string) => void–Called when a timeframe tab is picked. Load that timeframe's data here.
formatPrice(price: number) => stringUSD with 2 decimalsFormats prices on the axis, tooltip and header.
heightnumber280Chart height in pixels, excluding the header.
loadingbooleanfalseShow a skeleton while data loads.
timeZonestring"UTC"Time zone for time labels. Fixed by default so server and client agree.

Also accepts every native <div> attribute, including ref and className.

  • Prices here are display numbers for chart geometry (not balances), so plain numbers are fine. Pass formatPrice to match your quote currency.
  • Time labels use a fixed time zone (UTC by default) so server and client render the same axis.

Accessibility

  • The chart is a focusable group: arrow keys move a crosshair candle by candle (Home and End jump), and each candle's OHLC is announced through a live region.
  • A visually hidden table carries every candle, so the data is available without the chart.
  • Timeframes are a labelled radio group with a sliding indicator; the header change shows an arrow and a sign as well as color.
  • New data reveals left to right with a clip-path. Reduced motion uses a plain fade and moves the indicator instantly.