Skip to content

Repository files navigation

Nativeline

npm version npm downloads license types

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

  • 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 SkPicture and composited off the JS thread. No per‑frame React re‑renders.
  • Frictionless formattersformatValue / formatTime are 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 background hook 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-handler

Then:

  1. Reanimated Babel plugin — add 'react-native-reanimated/plugin' last in babel.config.js plugins.
  2. Gesture handler root — wrap your app in <GestureHandlerRootView style={{ flex: 1 }}>.
  3. Build a dev clientnpx 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). value is 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>
  )
}
  • window is the trailing span in seconds (default 30). Keep a little more data than window so the left edge has something to fade out.
  • Polling instead of a socket? Same shape — replace socket.onTick with a setInterval that 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 data array; 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 formatTime for the axis labels and formatHoverTime for 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.
  • badgeMomentumColortrue (default) colors the badge green/red by momentum; set false to keep it the accent color (useful when color already encodes direction).
  • momentumfalse hides the arrows entirely; 'up' / 'down' forces the direction; true auto‑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:ios

Roadmap

Feature‑complete — every liveline mode is ported plus the static viewport. Before 1.0:

  • Line + candlestick + multi‑series + orderbook + degen
  • Live + static viewports
  • Unit tests for the pure core (49)
  • 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.

About

Real-time animated charts for React Native + Expo — Skia port of liveline, with live/static viewports. Line, candlestick, multi-series, orderbook, degen.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages