nativeline
v0.0.4
Published
Real-time animated charts for React Native + Expo — line, candlestick, and multi-series modes, Skia-rendered, 60fps on the UI thread, with live and static viewports
Maintainers
Readme
Nativeline
Real-time animated charts for React Native + Expo. Line, candlestick, multi‑series, orderbook, and degen modes — Skia‑rendered at 60fps, with live and static viewports.
Nativeline is a React Native port of liveline: the same buttery interpolation engine and visual language, rebuilt on @shopify/react-native-skia + Reanimated, plus a static viewport for historical / fixed‑range data that liveline can't do.
import { Nativeline } from 'nativeline'
<View style={{ height: 300 }}>
<Nativeline data={data} value={price} viewport="live" color="#3b82f6" />
</View>Contents
- Features
- Install
- Quick start
- Viewports: live vs. static
- Modes
- Theming
- Props
- Recipes
- Performance
- Troubleshooting / FAQ
- Architecture
- Why a port instead of liveline?
- Contributing
- Roadmap
- License
Features
- Five modes, one component — line, candlestick (with a line↔candle morph), multi‑series, orderbook stream, and "degen" particles + shake. Selected by props, not different components.
- Live and static viewports — trail the wall clock like a ticker, or pin a fixed historical domain. Both still animate (value/range lerps, entrance morphs, scrub).
- 60fps on Skia — every frame is recorded as an
SkPictureand composited off the JS thread. No per‑frame React re‑renders. - Frictionless formatters —
formatValue/formatTimeare ordinary JS closures (no'worklet'), so you can format currency, dates, anything. - Scrub / crosshair with a gesture‑driven tooltip that glides back to the live tip on release.
- Momentum aware — auto up/down/flat detection drives the badge color, dot, and optional arrows.
- Fully themeable from a single accent color, light/dark, with a
backgroundhook so labels never halo on a colored card. - TypeScript‑first, dual ESM + CJS, tree‑shakeable, zero runtime deps (Skia / Reanimated / Gesture Handler are peers).
Install
Nativeline renders with Skia, a native module — it works with Expo via a dev client / prebuild / EAS build, not Expo Go.
npm install nativeline
# peers (install the versions your app already uses):
npx expo install @shopify/react-native-skia react-native-reanimated react-native-gesture-handlerThen:
- Reanimated Babel plugin — add
'react-native-reanimated/plugin'last inbabel.config.jsplugins. - Gesture handler root — wrap your app in
<GestureHandlerRootView style={{ flex: 1 }}>. - Build a dev client —
npx expo run:ios/run:android, or an EAS dev build. (Expo Go can't load Skia.)
Peer versions (minimums): react >=18, react-native >=0.73, @shopify/react-native-skia >=1.5.0, react-native-reanimated >=3.10.0, react-native-gesture-handler >=2.16.0.
Quick start
import { useEffect, useRef, useState } from 'react'
import { View } from 'react-native'
import { Nativeline } from 'nativeline'
import type { NativelinePoint } from 'nativeline'
export function LivePrice() {
const [data, setData] = useState<NativelinePoint[]>([])
const [value, setValue] = useState(100)
const v = useRef(100)
useEffect(() => {
const id = setInterval(() => {
v.current = Math.max(1, v.current + (Math.random() - 0.5) * 2)
const now = Date.now() / 1000
setValue(v.current)
setData((prev) => [...prev, { time: now, value: v.current }].slice(-300))
}, 500)
return () => clearInterval(id)
}, [])
return (
<View style={{ height: 300 }}>
<Nativeline
data={data}
value={value}
viewport="live"
window={30}
color="#3b82f6"
theme="dark"
formatValue={(v) => `$${v.toFixed(2)}`}
/>
</View>
)
}Data shape. Every point is
{ time: unixSeconds, value: number }, ordered ascending by time (a caller contract — nativeline doesn't sort for you).valueis the current value that drives the badge and the live dot.
Feeding data — live & static
The viewport is nativeline's one real addition over liveline. viewport only changes how the X axis behaves; you feed the points either way. Both viewports animate — the difference is whether the axis chases the wall clock.
| | viewport="live" | viewport="static" |
|---|---|---|
| X axis | trails Date.now() | fixed domain (or auto‑fit) |
| Scrolls with time | ✅ | ❌ |
| Animates value/range/scrub | ✅ | ✅ |
| Left‑edge fade | ✅ | ❌ |
| Use for | tickers, live feeds | history, ranges, snapshots |
Live data — stream ticks in
Keep an array of points in state and append each tick, trimming to a rolling window. Drive value from the same tick. The axis trails Date.now(), so the point's time must be current (Date.now() / 1000). Do the fast mutation on a ref and let a light setState push the array — the chart itself never re‑renders React (it reads the config via a ref and animates on its own loop).
function LiveChart({ socket }: { socket: PriceSocket }) {
const [data, setData] = useState<NativelinePoint[]>([])
const [value, setValue] = useState(0)
useEffect(() => {
const off = socket.onTick((price) => {
const now = Date.now() / 1000
setValue(price)
setData((prev) => {
const next = [...prev, { time: now, value: price }]
// keep a rolling window a bit larger than what's shown (window=30s here)
const cutoff = now - 120
return next[0] && next[0].time < cutoff ? next.filter((p) => p.time >= cutoff) : next
})
})
return off
}, [socket])
return (
<View style={{ height: 300 }}>
<Nativeline data={data} value={value} viewport="live" window={30} color="#4d8bff" />
</View>
)
}windowis the trailing span in seconds (default 30). Keep a little more data thanwindowso the left edge has something to fade out.- Polling instead of a socket? Same shape — replace
socket.onTickwith asetIntervalthat fetches the latest price. - Gaps are fine: if ticks stop, the last point simply trails toward the left as the clock moves.
Static data — load a historical range
Fetch a fixed dataset once, hand it over with viewport="static", and set value to the last point. Give a domain (unix seconds) to pin the exact range, or omit it to auto‑fit to the data's own extent. Nothing chases the clock — but the entrance, value/range lerps, and scrub still animate.
function HistoryChart({ tokenId, range }: { tokenId: string; range: '1D' | '1W' | '1M' }) {
const [data, setData] = useState<NativelinePoint[]>([])
// Re-fetch whenever the range changes — each range is its own static dataset.
useEffect(() => {
let alive = true
fetchCandles(tokenId, range).then((rows) => {
if (!alive) return
setData(rows.map((r) => ({ time: r.t, value: r.close })))
})
return () => { alive = false }
}, [tokenId, range])
const last = data.length ? data[data.length - 1].value : 0
return (
<View style={{ height: 300 }}>
<Nativeline
data={data}
value={last}
viewport="static"
// domain={{ from: rangeStartSecs, to: rangeEndSecs }} // or omit to auto-fit
color="#4d8bff"
formatValue={(v) => `$${v.toFixed(2)}`}
formatTime={(t) => new Date(t * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
/>
</View>
)
}- Switching ranges = swapping the
dataarray; nativeline morphs the range/value smoothly on the change. - Candles? Same idea — pass
candles/candleWidth(+mode="candle") instead of a value line. See Modes. - Use
formatTimefor the axis labels andformatHoverTimefor a range‑aware scrub tooltip (date on wide ranges, time intraday).
Modes
<Nativeline> is a single component; mode / series / orderbook / degen select what it draws.
Line (default)
Line + gradient fill + live dot + pulse + momentum arrows.
<Nativeline data={data} value={value} color="#3b82f6" />Candlestick
OHLC bars with a smooth morph to/from the line. Pass data/lineData too to enable the morph; hover reads O/H/L/C, and the value badge shows beside the latest candle (colored by the candle's bull/bear).
<Nativeline
mode="candle"
candles={candles} // { time, open, high, low, close }[]
candleWidth={3600} // candle interval, seconds
data={lineData} value={last} lineData={lineData} lineValue={last}
onModeChange={(m) => setMode(m)}
/>Multi‑series
Several lines on one shared axis, per‑series dots + a combined crosshair, and built‑in show/hide chips. Overrides data/value.
<Nativeline
series={[
{ id: 'btc', label: 'BTC', color: '#f7931a', value: 100, data: btc },
{ id: 'eth', label: 'ETH', color: '#627eea', value: 100, data: eth },
]}
seriesToggle // built-in chips; set false to use your own legend
formatValue={(v) => `${Math.round(v)}%`}
/>Orderbook
A Kalshi‑style rising $size stream (green bids / red asks) behind a live line; its speed follows price momentum and how fast the book churns.
<Nativeline
data={data}
value={value}
orderbook={{ bids: [[price, size], /* … */], asks: [[price, size], /* … */] }}
/>Degen
Particle bursts fly off the tip on up‑swings, and the whole chart briefly shakes.
<Nativeline data={data} value={value} degen />
// or tune it:
<Nativeline data={data} value={value} degen={{ scale: 1.4, downMomentum: true }} />Theming
Everything derives from a single accent color + theme. Momentum colors are always semantic green/red.
<Nativeline
color="#3b82f6" // accent: line, fill, badge, dot-flat
theme="dark" // 'light' | 'dark'
background={cardBg} // set to your container color so label outlines/fades don't halo
lineWidth={2}
/>background— nativeline fades grid labels and outlines the orderbook stream toward the background rather than with alpha. If your<Canvas>sits on a card that isn't the theme's default background, pass that card color here so nothing gets a dark halo.badgeMomentumColor—true(default) colors the badge green/red by momentum; setfalseto keep it the accentcolor(useful whencoloralready encodes direction).momentum—falsehides the arrows entirely;'up'/'down'forces the direction;trueauto‑detects.
Props
data and value are the only required props. Everything else is optional.
Data
| Prop | Type | Default | Notes |
|---|---|---|---|
| data | NativelinePoint[] | — | { time: unixSeconds, value }, ascending by time |
| value | number | — | current value → badge + live dot |
| series | NativelineSeries[] | — | multi‑series; overrides data/value |
Viewport
| Prop | Type | Default | Notes |
|---|---|---|---|
| viewport | 'live' \| 'static' | 'live' | live trails the clock; static is a fixed domain |
| domain | { from, to } | auto‑fit | static X range (unix seconds) |
| window | number | 30 | live trailing window, seconds |
Modes
| Prop | Type | Default | Notes |
|---|---|---|---|
| mode | 'line' \| 'candle' | 'line' | with candles + candleWidth |
| candles | CandlePoint[] | — | { time, open, high, low, close }[] |
| candleWidth | number | — | candle interval, seconds |
| liveCandle | CandlePoint | — | in‑progress candle that updates each tick |
| lineData / lineValue | NativelinePoint[] / number | — | enable the line↔candle morph |
| orderbook | OrderbookData | — | { bids: [price,size][], asks: [price,size][] } (line mode) |
| degen | boolean \| { scale?, downMomentum? } | false | particles + shake (line mode) |
Appearance
| Prop | Type | Default | Notes |
|---|---|---|---|
| color | string | #3b82f6 | accent |
| theme | 'light' \| 'dark' | 'dark' | |
| background | string | theme bg | match your container to kill label halos |
| lineWidth | number | 2 | |
| grid fill scrub pulse | boolean | true | feature flags |
| xAxis / yAxis | boolean | true | hide the time / value axis labels for compact cards (grid lines stay — use grid={false} for no lines) |
| badge | boolean | true | the value pill at the tip — shows in candle mode too |
| badgeScale | number | 1 | badge size multiplier |
| badgeMomentumColor | boolean | true | false = badge uses color |
| badgeVariant | 'default' \| 'minimal' | 'default' | |
| badgeTail | boolean | true | the little pointer on the pill |
| momentum | boolean \| 'up' \| 'down' | true | false hides arrows; string forces direction |
| referenceLine | { value, label? } | — | horizontal marker |
| padding | { top, right, bottom, left } | auto | the right gutter auto‑measures the widest grid/badge number so labels never clip; pass right to override |
| showValue | boolean | false | large value overlay above the chart |
| valueMomentumColor | boolean | false | color the showValue overlay by momentum |
| tooltipY / tooltipOutline | number / boolean | 14 / true | scrub tooltip position / outline |
Multi‑series
| Prop | Type | Default | Notes |
|---|---|---|---|
| seriesToggle | boolean | true | built‑in show/hide chips |
| seriesToggleCompact | boolean | false | dots only, no labels |
| onSeriesToggle | (id, visible) => void | — | toggle callback |
Window buttons
| Prop | Type | Notes |
|---|---|---|
| windows | { label, secs }[] | render live‑window buttons |
| onWindowChange | (secs) => void | window callback |
| windowStyle | 'default' \| 'rounded' \| 'text' | button style |
Formatters & callbacks
| Prop | Type | Notes |
|---|---|---|
| formatValue | (v: number) => string | axis + badge + tooltip (plain JS) |
| formatTime | (t: number) => string | time axis labels |
| formatHoverTime | (t: number) => string | scrub tooltip time (falls back to formatTime) |
| onHover | (HoverPoint \| null) => void | scrub callback → { time, value, x, y } |
| onModeChange | (mode) => void | |
React Native
| Prop | Type | Notes |
|---|---|---|
| style | StyleProp<ViewStyle> | on the wrapping view |
| fonts | { label?, tooltip?, badge?, value? } | per‑part Skia SkFont overrides (grid, scrub tooltip, badge, showValue) |
| labelFontFamily | string | monospace family (e.g. 'Menlo') — applies to every part unless overridden per‑font |
| loading paused | boolean | loading shimmer / freeze |
| emptyText | string | empty‑state label |
| exaggerate | boolean | tighter range for near‑flat data |
| lerpSpeed | number | smoothing speed (0–1) |
Full types: src/types.ts.
Recipes
Persist the user's line/candle choice (e.g. with zustand + AsyncStorage):
const mode = useChartStore((s) => s.mode) // default 'line'
<Nativeline mode={mode} onModeChange={useChartStore.getState().setMode} /* … */ />Drive a price display from the crosshair:
const [cursor, setCursor] = useState<number | null>(null)
<Nativeline data={data} value={value} onHover={(p) => setCursor(p ? p.value : null)} />
<Text>{formatMoney(cursor ?? value)}</Text>Range‑aware hover time (date on wide ranges, time intraday):
<Nativeline
formatTime={(t) => fmtAxis(t, range)}
formatHoverTime={(t) => (range === '1D' ? fmtTime(t) : fmtDate(t))}
/>Hide the momentum arrows but keep the momentum‑colored badge:
<Nativeline momentum={false} />Live window buttons:
<Nativeline
windows={[{ label: '15s', secs: 15 }, { label: '1m', secs: 60 }, { label: '5m', secs: 300 }]}
onWindowChange={(secs) => console.log('window', secs)}
/>Compact card — a spark‑style chart with no axis clutter (grid lines stay, labels go):
<Nativeline data={data} value={value} xAxis={false} yAxis={false} />
// or drop the grid lines too:
<Nativeline data={data} value={value} grid={false} />Custom per‑part typography — override any font independently (build them with Skia's matchFont):
import { matchFont } from '@shopify/react-native-skia'
const mono = { fontFamily: 'Menlo', fontSize: 11 }
<Nativeline
labelFontFamily="Menlo" // family for every part
fonts={{
label: matchFont(mono), // grid / axis labels
tooltip: matchFont({ fontFamily: 'Inter', fontSize: 13 }), // scrub tooltip
badge: matchFont({ ...mono, fontSize: 12 }), // the value pill
}}
/>Performance
Nativeline runs its animation loop on the JS thread (a requestAnimationFrame loop mutating refs, exactly like liveline) and records an SkPicture per frame that Skia composites off‑thread. React never re‑renders on data updates — only on discrete UI events (window pill, hidden series, mode toggle).
This is a deliberate trade‑off: the loop being on the JS thread is what lets your formatValue / formatTime be ordinary JS. If your JS thread is busy (heavy lists, frequent setState), you may see occasional jank — keep the surrounding screen light, and feed data with a ref + throttled setState rather than on every event.
Hot paths (like the particle field) batch their Skia draws through a single reused Paint to keep per‑frame allocation — and GC — low.
Troubleshooting / FAQ
"Unable to resolve nativeline" after installing. Almost always a stale Metro cache — restart with npx expo start -c. Metro builds its resolver map at startup, so a plain reload won't pick up a new dependency.
"PlatformConstants could not be found" / a second React Native instance. A duplicate copy of react-native (or Skia/Reanimated) got bundled. Make sure nativeline's peers resolve to your app's single copies — a normal npm install does this; if you're linking nativeline locally, force single copies of the peers in metro.config.js.
Nothing renders / blank canvas. You're probably on Expo Go — Skia needs a dev client (expo run:ios / EAS dev build). Also confirm the chart has a fixed height (<View style={{ height: 300 }}>).
Labels have a dark halo on my card. Pass background={yourCardColor} so nativeline fades/outlines toward the actual background instead of the theme default.
The badge overlaps my y‑axis labels. Give the chart room on the right: padding={{ right: 64 }} (it auto‑reserves some, but tune it to your formatValue width).
Tiny prices show weird axis labels. nativeline keeps precision for tiny values — pass a formatValue that renders them the way you want (e.g. subscript notation).
Architecture
Four layers, one stateful node — identical shape to liveline:
Component Nativeline.tsx public API, prop normalization, chrome, discrete UI state
│
Engine engine/useEngine.ts JS-thread rAF loop, refs, the live/static viewport provider;
│ builds ChartLayout {toX,toY} each frame, records an SkPicture
Draw draw/* · skia/gfx.ts Skia painting (PictureRecorder), fixed painter's order
│
Math/Theme math/* · theme.ts numeric core (spline, lerp, range, momentum, interpolate)Each frame the engine advances all animated state, records an SkPicture in strict back‑to‑front order, and assigns it to a Reanimated shared value. <Canvas><Picture picture={sv}/></Canvas> composites that picture off the JS thread.
Why a port instead of liveline?
liveline is Canvas 2D + DOM + requestAnimationFrame — none of which exist in React Native. Nativeline keeps liveline's brain (all numeric + animation logic is portable) and replaces its eyes:
| Concern | liveline (web) | nativeline (RN) |
|---|---|---|
| Rendering surface | <canvas> / CanvasRenderingContext2D | @shopify/react-native-skia |
| Frame loop | requestAnimationFrame | requestAnimationFrame → records an SkPicture |
| Curve | ctx.bezierCurveTo | path.cubicTo (same Fritsch‑Carlson spline) |
| Left‑edge fade | globalCompositeOperation | BlendMode.DstOut |
| Text measure | ctx.measureText | SkFont.measureText |
| Hover / scrub | mouse + touch | react-native-gesture-handler Pan |
| showValue overlay | DOM + CSS | Reanimated <Animated.Text> |
The math and theme derivation port near‑verbatim. The engine deliberately runs on the JS thread (not a worklet), so your formatter closures are ordinary JS.
Fixes baked in vs. liveline: lerp speed clamped to [0, 0.999] (a speed >= 1 produced NaN that poisoned every smoothed value); non‑finite data sanitized at ingestion; a single unified color parser (liveline had two that disagreed); a bounded grid‑label loop; reduced‑motion honored in the candle pipeline.
Contributing
git clone https://github.com/ministic-dev/nativeline
cd nativeline
npm install
npm test # vitest — 49 tests over the pure core
npm run typecheck # tsc --noEmit
npm run build # tsup → dist (ESM + CJS + d.ts)The example/ folder is a standalone Expo app that exercises every mode:
cd example && npm install && npx expo run:iosRoadmap
Feature‑complete — every liveline mode is ported plus the static viewport. Before 1.0:
- [x] Line + candlestick + multi‑series + orderbook + degen
- [x] Live + static viewports
- [x] Unit tests for the pure core (49)
- [x] Standalone example app (all six modes)
- [ ] More prop‑level tests / visual regression
- [ ] Web target (Skia web)
License
MIT. Portions ported from liveline (MIT, © 2026 Benji Taylor) — see LICENSE.
