| name | lightweight-charts-integration |
| description | Use when integrating TradingView lightweight-charts in a React/canvas app, especially when candles or overlays silently fail to render, the crosshair behaves unexpectedly, or color values appear to "do nothing." Covers data-domain alignment between candle and custom-series, isWhitespace data-stripping, the IChartApi vs ISeriesApi API split for crosshair, hex-only color requirements, and the time-collapse trick for collapsing market gaps. |
| metadata | {"category":"charting"} |
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
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:
candleSeries.setCrosshairPosition(price, time, candleSeries);
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 }
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
granularitySec steps: bar 0 → time = T0, bar 1 → T0 + granularitySec, …
- Build a
Map<logicalSeconds, realUnixSeconds> for the axis formatter to translate back
- Pass
localization.timeFormatter that 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 onMove = (param) => { ... };
chart.subscribeCrosshairMove(onMove);
return () => chart.unsubscribeCrosshairMove(onMove);
Gotchas
- The chart appears empty but the canvas has dimensions. Most likely cause: a color value in
layout is oklch() or similar; check the chart options object in DevTools. Second most likely: time-domain mismatch (#5).
- Tooltip shows "Jan '70" times. You forgot
localization.timeFormatter and have time-compression on (#6).
- Custom series renderer never sees session-boundary rows.
isWhitespace is returning true for 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 === real map.
Reference implementation
These patterns came from a working charting project. The shape there was roughly:
- A
logical-time.ts module with buildLogicalTimeMap / projectLiveBar helpers for the gap-collapse scheme
- A main
candlestick-chart.tsx React component that reads CSS variables via getComputedStyle and passes hex strings into the chart options, sets crosshair.mode = 0, supplies a localization.timeFormatter that does the logical → real lookup
- An
ICustomSeriesPaneView indicator implementation (level-custom-series.ts) where isWhitespace returns false unconditionally and the renderer uses Number.isFinite checks 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 tokens
react-canvas-race-conditions — first-tick OHLC wipe and the ref-lifting fix
browser-verify-ui-changes — paint-time bugs are exactly what you can't catch with unit tests