@stacklatte/chart-core
High-performance React Native trading charts — GPU-accelerated via React Native Skia. Works on iOS and Android.
Why chart-core
- One component, not a kit. Candlesticks, line series, SMA/EMA/Bollinger Bands overlays, RSI + MACD sub-panels, pinch-zoom, pan, crosshair inspection, and live-streaming updates all ship built in — you're not assembling them from a primitives library.
- Real indicator math. RSI uses Wilder smoothing, MACD is seeded and aligned correctly (12/26/9), Bollinger Bands use an O(n) sliding window — not approximations. 183 unit tests cover the math directly, independent of rendering.
- GPU-rendered, not SVG. Built on Skia via
react-native-skia— smooth at 60fps with hundreds of visible candles and a live feed ticking underneath. - LLM-safe declarative config.
buildChartPipeline()takes a versionedChartConfigdocument — every color and interval is a closed enum, never a raw string an LLM can typo — and returns validated, ready-to-render props, or throws a precise, itemized error. A generated JSON Schema ships indist/ChartConfig.schema.jsonfor structured output / tool-calling. Seellms.txtfor the quick path. - Also available for the web.
@stacklatte/chart-webis the same engine, same props, rendered with Canvas 2D — no React Native required.
| chart-core / chart-web | General-purpose chart libs (Recharts, Victory) | Web-only trading chart libs | |
|---|---|---|---|
| Candlesticks + OHLC | built in | build it yourself | |
| RSI / MACD / Bollinger | built in | varies by plugin | |
| Pinch-zoom, pan, crosshair | built in | manual | |
| Native (iOS/Android) and web | two packages, one shared core | web only | web only |
| Declarative config + JSON Schema | |||
| TypeScript, strict | varies | varies |
Install
npm install @stacklatte/chart-core @shopify/react-native-skia react-native-gesture-handler
Follow the setup guides for react-native-skia and react-native-gesture-handler.
Peer dependencies
| Package | Version |
|---|---|
react |
>=19.0.0 |
react-native |
>=0.79.0 |
@shopify/react-native-skia |
>=2.0.0 |
react-native-gesture-handler |
>=2.0.0 |
Candlestick chart
Drop in OHLC candle data and get a GPU-rendered, pannable, zoomable chart in one component.
import { SLChart } from '@stacklatte/chart-core';
import type { Candle } from '@stacklatte/chart-core';
import { useWindowDimensions } from 'react-native';
const candles: Candle[] = [
{ timestamp: 1700000000000, open: 42000, high: 43500, low: 41800, close: 43100 },
{ timestamp: 1700003600000, open: 43100, high: 44200, low: 42900, close: 44000 },
// ...
];
export default function TradingScreen() {
const { width } = useWindowDimensions();
return (
<SLChart
data={candles}
width={width}
height={400}
theme="dark"
/>
);
}
Line chart
<SLChart data={candles} width={width} height={400} chartType="line" theme="dark" />
Moving average overlays
Calculate SMA and EMA outside the component and pass them in as IndicatorLine objects.
import { SLChart, calcSMA, calcEMA } from '@stacklatte/chart-core';
const sma20 = calcSMA(candles, 20, 'sma-20', '#F5A623');
const ema9 = calcEMA(candles, 9, 'ema-9', '#4a90e2');
const ema21 = calcEMA(candles, 21, 'ema-21', '#50E3C2');
<SLChart
data={candles}
indicators={[sma20, ema9, ema21].filter(Boolean)}
width={width}
height={400}
/>
Bollinger Bands
Bollinger Bands return three indicator lines (upper, basis, lower). Pass a ShadedArea to fill the region between the bands.
import { SLChart, calcBollingerBands } from '@stacklatte/chart-core';
const bb = calcBollingerBands(candles, 20, 2, {
upper: '#9B59B6',
basis: '#7F8C8D',
lower: '#9B59B6',
});
<SLChart
data={candles}
indicators={bb ? [bb.upper, bb.basis, bb.lower] : []}
shadedAreas={bb ? [{ fromId: 'bb-upper', toId: 'bb-lower', color: '#9B59B6', opacity: 0.1 }] : []}
width={width}
height={400}
/>
RSI sub-panel
Displays a Relative Strength Index panel below the main chart. RSI is computed internally using Wilder smoothing (period 14) on the visible candles.
<SLChart
data={candles}
showRsiPanel
width={width}
height={480} // extra height for the sub-panel
/>
The panel includes 30/70 overbought/oversold threshold lines and a synchronized crosshair.
MACD sub-panel
MACD (12, 26, 9) — line, signal, and histogram bars — in a panel synchronized with the main chart.
<SLChart
data={candles}
showMacdPanel
width={width}
height={480}
/>
Run both panels at once:
<SLChart
data={candles}
showRsiPanel
showMacdPanel
width={width}
height={560}
/>
OHLC HUD
An overlay that shows Open, High, Low, Close values for the currently inspected candle — and any indicator values at that timestamp.
<SLChart
data={candles}
showOhlcHud
indicators={[ema9, ema21]}
width={width}
height={400}
/>
When no touch is active, the HUD shows the latest candle. On tap or drag it locks to the touched candle.
Crosshair callback
<SLChart
data={candles}
showOhlcHud
onCrosshairChange={(candle) => {
if (candle) {
console.log(`Price: ${candle.close} at ${new Date(candle.timestamp).toISOString()}`);
}
}}
width={width}
height={400}
/>
Zoom and pan
Pinch to zoom and pan are built-in — no setup required.
<SLChart
data={candles}
visibleDataPoints={60} // candles visible at initial load
maxZoom={400} // maximum candles visible (widest zoom out)
width={width}
height={400}
/>
| Gesture | Action |
|---|---|
| 1-finger pan | Scroll the time axis |
| 2-finger pinch | Zoom in/out around the focal point |
Tap or pan (with showOhlcHud) |
Lock the crosshair to a candle |
| Pan to the right edge | Re-engage live-follow mode |
Live / streaming data
The chart auto-follows new candles when the viewport is at the latest data. After the user scrolls away, increment scrollToLatestTrigger to snap back.
const [candles, setCandles] = useState<Candle[]>(initialCandles);
const [trigger, setTrigger] = useState(0);
function onNewCandle(updatedCandles: Candle[]) {
setCandles(updatedCandles);
setTrigger(t => t + 1); // snaps back to latest
}
<SLChart
data={candles}
scrollToLatestTrigger={trigger}
width={width}
height={400}
/>
Themes
Built-in presets
<SLChart data={candles} theme="dark" width={width} height={400} />
<SLChart data={candles} theme="light" width={width} height={400} />
Custom theme
Pass any ChartThemeColors object to override every color token.
import type { ChartThemeColors } from '@stacklatte/chart-core';
const myTheme: ChartThemeColors = {
background: '#0d0d0d',
gridH: '#1a1a1a',
gridV: '#141414',
priceLabel: '#999999',
timeLabel: '#555555',
crosshair: '#777777',
candleUp: '#26a69a',
candleDown: '#ef5350',
lineChart: '#00e5ff',
rsiLine: '#f1c40f',
rsiThreshold: '#2a2a2a',
hudBackground: 'rgba(0,0,0,0.85)',
hudText: '#ffffff',
};
<SLChart data={candles} theme={myTheme} width={width} height={400} />
All together
import { SLChart, calcEMA, calcBollingerBands } from '@stacklatte/chart-core';
import type { Candle, IndicatorLine, ShadedArea } from '@stacklatte/chart-core';
import { useMemo, useState } from 'react';
import { useWindowDimensions } from 'react-native';
export default function TradingScreen({ candles }: { candles: Candle[] }) {
const { width } = useWindowDimensions();
const [trigger, setTrigger] = useState(0);
const { indicators, shadedAreas } = useMemo(() => {
const lines: IndicatorLine[] = [];
const areas: ShadedArea[] = [];
const ema9 = calcEMA(candles, 9, 'ema-9', '#4a90e2');
const ema21 = calcEMA(candles, 21, 'ema-21', '#50E3C2');
if (ema9) lines.push(ema9);
if (ema21) lines.push(ema21);
const bb = calcBollingerBands(candles, 20, 2, {
upper: '#9B59B6', basis: '#7F8C8D', lower: '#9B59B6',
});
if (bb) {
lines.push(bb.upper, bb.basis, bb.lower);
areas.push({ fromId: 'bb-upper', toId: 'bb-lower', color: '#9B59B6', opacity: 0.1 });
}
return { indicators: lines, shadedAreas: areas };
}, [candles]);
return (
<SLChart
data={candles}
indicators={indicators}
shadedAreas={shadedAreas}
width={width}
height={560}
theme="dark"
chartType="candle"
showOhlcHud
showRsiPanel
showMacdPanel
visibleDataPoints={60}
scrollToLatestTrigger={trigger}
onCrosshairChange={(c) => c && console.log('candle', c.close)}
/>
);
}
Declarative config (ChartConfig) — and generating it with an LLM
Every example above sets SLChart props directly. There's a second, declarative path: describe the chart as a versioned ChartConfig document and let buildChartPipeline() validate, normalize, and compile it into props for you.
import { buildChartPipeline, SLChart } from '@stacklatte/chart-core';
import type { ChartConfig } from '@stacklatte/chart-core';
const config: ChartConfig = {
version: '1',
theme: 'dark',
priceDisplay: 'candle',
interval: '1h',
overlays: [
{ type: 'ema', id: 'ema-9', params: { period: 9 }, color: 'cyan' },
{
type: 'bollinger_bands',
params: { period: 20, stdDev: 2 },
bands: {
upper: { id: 'bb-upper', color: 'purple' },
lower: { id: 'bb-lower', color: 'purple' },
basis: { id: 'bb-basis', color: 'gray' },
},
},
],
shadedAreas: [{ fromOverlayId: 'bb-upper', toOverlayId: 'bb-lower', color: 'purple', opacity: 0.15 }],
subPanels: [{ type: 'rsi', params: { period: 14 } }],
viewport: { visibleCandles: 100, followLive: true },
hud: { showOhlcHud: true },
};
const compiled = buildChartPipeline(config, candles);
<SLChart {...compiled} width={width} height={560} />
This exists specifically so an LLM (or any code-generation step) can produce chart setup safely:
- Every string is a closed enum —
theme,interval, overlaytype, and everycolorcome from a fixed, named union (PaletteColor,ChartInterval, …). There's no raw hex, no arbitrary millisecond literal, no field an LLM can plausibly hallucinate a wrong-but-valid-looking value for. - It fails loudly, not silently.
buildChartPipelinerunsvalidateChartConfigfirst and throws one error listing every problem ([code] path: message) if the document doesn't match the schema — an agent gets something it can parse and self-correct from, instead of a chart that renders with subtly wrong data. - A matching JSON Schema ships in the package at
dist/ChartConfig.schema.json(draft-07,additionalProperties: falsethroughout) — drop it straight into an LLM's structured-output or tool-call schema so the model can only produce a validChartConfigin the first place.
See llms.txt for a condensed reference aimed at coding agents.
Standalone indicator calculators
All calculators are exported for use outside the chart component — useful for displaying values in a separate UI element.
import { calcSMA, calcEMA, calcBollingerBands, calcRSI, calcMACD } from '@stacklatte/chart-core';
import type { RsiPoint, MacdResult } from '@stacklatte/chart-core';
const sma = calcSMA(candles, 20, 'sma-20', '#F5A623'); // IndicatorLine | null
const ema = calcEMA(candles, 20, 'ema-20', '#4a90e2'); // IndicatorLine | null
const bb = calcBollingerBands(candles, 20, 2, colors); // { upper, basis, lower } | null
const rsi = calcRSI(candles, 14); // RsiPoint[]
const macd = calcMACD(candles, 12, 26, 9); // MacdResult | null
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data |
Candle[] |
required | OHLC candles sorted ascending by timestamp (ms) |
width |
number |
required | Canvas width in logical pixels |
height |
number |
required | Canvas height in logical pixels |
theme |
"dark" | "light" | ChartThemeColors |
"dark" |
Color preset or full custom theme |
chartType |
"candle" | "line" |
"candle" |
Main series rendering style |
indicators |
IndicatorLine[] |
[] |
Overlay lines (SMA, EMA, Bollinger Bands) |
shadedAreas |
ShadedArea[] |
[] |
Filled band between two indicator lines |
visibleDataPoints |
number |
60 |
Number of candles visible at initial load |
intervalMs |
number |
auto | Milliseconds per candle — inferred from data if omitted |
showGrid |
boolean |
true |
Horizontal and vertical grid lines |
showOhlcHud |
boolean |
false |
OHLC info overlay |
showRsiPanel |
boolean |
false |
RSI sub-panel |
showMacdPanel |
boolean |
false |
MACD sub-panel |
maxZoom |
number |
data length | Maximum candles visible at once |
hour12 |
boolean |
false |
12-hour (AM/PM) time axis |
scrollToLatestTrigger |
number |
— | Increment to snap to latest candle |
onCrosshairChange |
(candle: Candle | null) => void |
— | Fires on crosshair lock/release |
Data format
interface Candle {
timestamp: number; // Unix milliseconds, sorted ascending
open: number;
high: number;
low: number;
close: number;
}
Architecture
Each drawing concern is a separate Skia component. Swap or extend individual layers without touching the rest.
| Layer | Renders |
|---|---|
AxisLayer |
Time/price grid lines and labels |
CandlestickLayer |
OHLC wicks and bodies |
LineLayer |
Close-price line |
IndicatorLayer |
Single indicator polyline |
ShadedAreaLayer |
Filled region between two series |
RsiLayer |
RSI sub-panel with 30/70 threshold lines |
MacdLayer |
MACD sub-panel (line, signal, histogram) |
CrosshairLayer |
Dashed vertical + horizontal crosshair |
OhlcHud |
OHLC info overlay |
All layers are exported for custom compositions:
import { SLChart, AxisLayer, CandlestickLayer, IndicatorLayer } from '@stacklatte/chart-core';
Contributing
Issues and PRs welcome. See the monorepo README for setup instructions.
License
MIT — see LICENSE.md.