# Lightweight Charts Integration

> 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.

- Skill: `traderspost/lightweight-charts-integration` (Agent Skill)
- Install (CLI): `npx skillmds@latest add traderspost/lightweight-charts-integration`
- Raw SKILL.md: https://api.skillmd.com/api/skills/traderspost/lightweight-charts-integration/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: traderspost (https://skillmd.com/u/traderspost)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/traderspost/lightweight-charts-integration

---


# 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:

```bash
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:

1. Your renderer can't detect transitions you want to highlight (e.g. session boundaries) because the boundary rows are gone.
2. 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.

```ts
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:

```ts
// 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.

```ts
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:

1. Renumber bars to sequential `granularitySec` steps: bar 0 → time = T0, bar 1 → T0 + granularitySec, …
2. Build a `Map<logicalSeconds, realUnixSeconds>` for the axis formatter to translate back
3. 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:

```ts
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

