Lightweight-charts integration — gotchas worth knowing before you start
lightweight-charts is one of the fastest open-source canvas charting libs. It's also one of the most laconic about failure modes. Most of the bugs below cost multiple iterations to find because they manifest as "the chart looks empty" or "the crosshair jumps to weird places" rather than thrown errors.
When to use
Reach for this skill if you're:
- Adding lightweight-charts to a React/Next.js app for the first time
- Building a multi-series chart (candles + overlays via
ICustomSeriesPaneView) - Synchronizing crosshairs between multiple chart instances
- Authoring color tokens that feed both Tailwind/CSS and the chart's canvas
- Seeing the candle series render fine on its own but go blank when an overlay is added
- Confused why
series.setCrosshairPosition(...)is throwing "not a function"
The pattern — what actually works
1. Colors must be hex (or rgb/rgba). Wide-gamut forms break canvas.
The chart's layout.background, upColor, downColor, etc. flow into Canvas2D's fillStyle. Canvas2D parses #rrggbb, rgb(), rgba(), hsl() — and rejects oklch(), lab(), the modern color() function. Worse: Chrome's getComputedStyle and ctx.fillStyle setter PRESERVE wide-gamut forms when you read them back. So reading a CSS variable into JS and forwarding it to the chart silently produces a lab(...) string the canvas can't parse, and the chart shows nothing.
Solution: author chart-consumed CSS tokens directly as hex. Enforce mechanically via a grep against your CSS variables file:
grep -nE ':[^;]*(oklch\(|\blab\(|\bcolor\()' globals.css # fails the build if any wide-gamut value present
Pick colors perceptually (OKLCH is great) but convert to hex once at design time. See design-system-mechanical-lint for a complete lint script.
2. isWhitespace returning true strips rows from data.bars
If you implement ICustomSeriesPaneView.isWhitespace(row) and return true for any row, the chart removes that row from data.bars before your draw() call. Two non-obvious failure modes follow:
- Your renderer can't detect transitions you want to highlight (e.g. session boundaries) because the boundary rows are gone.
- Any code that does index-based cross-row lookups (e.g. "look up the bar at position i in this other Map") goes out of sync — the indices no longer match the input bars array.
Solution: always return false. Handle NaN-valued rows by checking Number.isFinite() inside draw() instead.
isWhitespace(_data: MyData): _data is MyData & { time: never } {
return false;
}
3. setCrosshairPosition lives on IChartApi, NOT ISeriesApi
When wiring crosshair sync across multiple chart instances:
// WRONG (v5 typings may suggest this; TS will reject)
candleSeries.setCrosshairPosition(price, time, candleSeries);
// RIGHT
chart.setCrosshairPosition(price, time, candleSeries);
The third argument is which series the price-axis label should display against. Pass your candle series.
4. Crosshair default is Magnet (mode 1) — Y-axis snaps to OHLC
By default the crosshair's Y line jumps to the closest candle OHLC value as the mouse moves. Most operators find this jumpy and want free-floating tracking on both axes.
crosshair: { mode: 0 } // Normal — free-floating on X and Y. Mode 1 = Magnet (default), 2 = Hidden.
5. Time-domain mismatch between candle and overlay silently kills rendering
If your candle series uses time domain A (e.g. real UTC seconds) and your custom-series overlay uses time domain B (e.g. sequential logical seconds from a time-compression map), lightweight-charts will silently render NEITHER series correctly. There's no console warning.
Solution: every series mounted on the same chart must share the time domain. If you compress time on the main chart (see #6) but want to mount a "simpler" secondary chart that uses real times, build a pass-through time map for the overlay so its setData calls land in the candle series' time domain.
6. Collapsing market gaps via logical-time mapping
lightweight-charts positions bars by their time value. A 1-hour gap (e.g. CME globex 17:00→18:00 ET maintenance break, weekends, holidays) renders as ~60 bar-widths of empty space. To collapse the gaps to a single bar-boundary:
- Renumber bars to sequential
granularitySecsteps: bar 0 → time = T0, bar 1 → T0 + granularitySec, … - Build a
Map<logicalSeconds, realUnixSeconds>for the axis formatter to translate back - Pass
localization.timeFormatterthat does the real-time lookup; otherwise hovering a post-break bar shows "02 Jan '70 22:00" (UTC epoch of the logical second)
The formatter step is non-obvious — tickMarkFormatter formats the AXIS but the crosshair tooltip uses localization.timeFormatter.
7. v5: markers split out from ISeriesApi
In v5, series.setMarkers() is gone. Use createSeriesMarkers(series, markers) from the standalone plugin and keep the returned handle to update markers later.
8. v5: subscribeCrosshairMove doesn't return an unsubscribe function
Hold the handler reference yourself, then call chart.unsubscribeCrosshairMove(handler) on teardown:
const => { ... };
chart.subscribeCrosshairMove(onMove);
return () => chart.unsubscribeCrosshairMove(onMove);
Gotchas
- The chart appears empty but the canvas has dimensions. Most likely cause: a color value in
layoutisoklch()or similar; check the chart options object in DevTools. Second most likely: time-domain mismatch (#5). - Tooltip shows "Jan '70" times. You forgot
localization.timeFormatterand have time-compression on (#6). - Custom series renderer never sees session-boundary rows.
isWhitespaceis returningtruefor the boundary marker rows (#2). - Crosshair "drifts" when moving the mouse. Y-magnet is on — switch to mode 0 (#4).
- Last bar of historical data renders but disappears on first WS tick. Race between bars-fetch and tick-stream (see
react-canvas-race-conditions). - Indicators inside a picture-in-picture chart blank the candle series. Time-domain mismatch (#5); pass through a
logical === realmap.
Reference implementation
These patterns came from a working charting project. The shape there was roughly:
- A
logical-time.tsmodule withbuildLogicalTimeMap/projectLiveBarhelpers for the gap-collapse scheme - A main
candlestick-chart.tsxReact component that reads CSS variables viagetComputedStyleand passes hex strings into the chart options, setscrosshair.mode = 0, supplies alocalization.timeFormatterthat does the logical → real lookup - An
ICustomSeriesPaneViewindicator implementation (level-custom-series.ts) whereisWhitespacereturnsfalseunconditionally and the renderer usesNumber.isFinitechecks on every cross-bar lookup - A picture-in-picture chart (
pip-chart.tsx) that mounts the same overlay with a pass-through time map because its candles use real UTC seconds
This is one valid concrete shape, not a required architecture. The skill content above is what matters; the file layout is illustrative.
Related skills
design-system-mechanical-lint— grep-based CI to keep hex-only tokensreact-canvas-race-conditions— first-tick OHLC wipe and the ref-lifting fixbrowser-verify-ui-changes— paint-time bugs are exactly what you can't catch with unit tests