lightweight-charts in a Next.js client component
components/candle-chart.tsx
"use client";
import { useEffect, useRef } from "react";
import {
CandlestickSeries,
ColorType,
HistogramSeries,
createChart,
type IChartApi,
type ISeriesApi,
type UTCTimestamp,
} from "lightweight-charts";
export type Candle = {
time: UTCTimestamp; // seconds since epoch, UTC
open: number;
high: number;
low: number;
close: number;
volume?: number;
};
/** Called once the chart exists. Push each new or changed bar to onBar; return a cleanup. */
export type Subscribe = (onBar: (bar: Candle) => void) => () => void;
const UP = "#22c55e";
const DOWN = "#ef4444";
const toBar = ({ time, open, high, low, close }: Candle) => ({ time, open, high, low, close });
const toVolume = (c: Candle) => ({
time: c.time,
value: c.volume ?? 0,
color: c.close >= c.open ? "rgba(34,197,94,0.35)" : "rgba(239,68,68,0.35)",
});
export function CandleChart({
candles,
subscribe,
className = "h-[420px] w-full",
}: {
candles: Candle[]; // oldest first
subscribe?: Subscribe;
className?: string;
}) {
const box = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const priceRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const volumeRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 1. Create the chart once, in the browser. createChart needs the DOM,
// so it lives in an effect, never in the render body.
useEffect(() => {
const el = box.current;
if (!el) return;
const chart = createChart(el, {
autoSize: true, // follows the div's size, no resize listener needed
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: "#a1a1aa",
attributionLogo: true, // the TradingView link the licence asks for
},
grid: {
vertLines: { color: "rgba(255,255,255,0.04)" },
horzLines: { color: "rgba(255,255,255,0.04)" },
},
rightPriceScale: { borderVisible: false },
timeScale: { borderVisible: false, timeVisible: true },
});
const price = chart.addSeries(CandlestickSeries, {
upColor: UP,
downColor: DOWN,
borderVisible: false,
wickUpColor: UP,
wickDownColor: DOWN,
});
price.priceScale().applyOptions({ scaleMargins: { top: 0.08, bottom: 0.28 } });
// Volume on its own overlay scale, squeezed into the bottom fifth.
const volume = chart.addSeries(HistogramSeries, { priceFormat: { type: "volume" }, priceScaleId: "" });
volume.priceScale().applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
chartRef.current = chart;
priceRef.current = price;
volumeRef.current = volume;
return () => {
chart.remove(); // frees the canvases; Strict Mode mounts twice in dev
chartRef.current = null;
priceRef.current = null;
volumeRef.current = null;
};
}, []);
// 2. Full data: first load, or a new symbol or interval. setData replaces everything.
useEffect(() => {
if (!priceRef.current || !volumeRef.current) return;
priceRef.current.setData(candles.map(toBar));
volumeRef.current.setData(candles.map(toVolume));
chartRef.current?.timeScale().fitContent();
}, [candles]);
// 3. Live bars: update() replaces the last bar when the time matches and
// appends when it is newer. No re-render, the zoom stays where it was.
useEffect(() => {
if (!subscribe) return;
return subscribe((bar) => {
priceRef.current?.update(toBar(bar));
volumeRef.current?.update(toVolume(bar));
});
}, [subscribe]);
return <div ref={box} className={className} />;
}