Skip to content

Repository files navigation

react-native-liveline

npm versionlicense

A React Native port of liveline by Benji Taylor — real-time animated charts (line, multi-series, candlestick) with the same SDK shape as the web version. The chart keeps animating even when your JS thread is blocked: the render engine runs entirely on the UI thread as a Reanimated worklet, painting into a Skia picture every frame, independent of React renders and JS-thread work.

All the hard design and animation work here — the curve fitting, the momentum/degen feel, the badge and crosshair interactions, the candlestick morph — is Benji's. This package adapts it to run natively via Reanimated worklets and Skia; see Credits below.

Installation

Published on npm: @ajfuller/react-native-liveline.

npm install @ajfuller/react-native-liveline

Peer dependencies (install alongside):

npm install @shopify/react-native-skia react-native-reanimated react-native-worklets react-native-gesture-handler
PeerRequired range
@shopify/react-native-skia>=2.0.0
react-native-reanimated>=4.0.0
react-native-worklets>=0.3.0
react-native-gesture-handler>=2.30.0

Reanimated 4 split its worklets runtime out into the separate react-native-worklets package (Reanimated itself declares this as its own peer dependency as of 4.0.0), so it's required alongside Reanimated here too. This library hasn't been tested against Reanimated 3.x.

gesture-handler 2.30 and 3.x are both supported. The scrub gesture uses the classic Gesture.Pan() builder API, which exists unchanged in both majors — 3.x keeps it working but marks it deprecated in favour of a new hook-based API, and migrating is deferred to a future release. The >=2.30.0 floor matters for Expo: SDK 55 pins ~2.30.0, so a narrower range would put every SDK 55 app into a peer conflict and an expo-doctor failure. Compatibility is verified by installing 2.30.0 and running the typecheck against it, not by inspection.

Reanimated Babel plugin

Add the Reanimated plugin to your babel.config.js (must be listed last):

module.exports={presets: ['module:@react-native/babel-preset'],plugins: [// ...your other plugins'react-native-reanimated/plugin',],};

GestureHandlerRootView

Scrubbing is implemented as a gesture-handler Pan gesture. Wrap your app root (once, near the top) in GestureHandlerRootView:

import{GestureHandlerRootView}from'react-native-gesture-handler';exportdefaultfunctionApp(){return(<GestureHandlerRootViewstyle={{flex: 1}}>{/* rest of your app */}</GestureHandlerRootView>);}

Expo

Because this library depends on native code (Skia, Reanimated, gesture-handler), it does not work in Expo Go. Use a development build (npx expo prebuild + a custom dev client) or a bare workflow app.

Quick start

import{useEffect,useState}from'react';import{View}from'react-native';import{Liveline}from'@ajfuller/react-native-liveline';importtype{LivelinePoint}from'@ajfuller/react-native-liveline';functionChart(){const[data,setData]=useState<LivelinePoint[]>([]);const[value,setValue]=useState(0);useEffect(()=>{// Feed data from a WebSocket, polling, etc.// Each point: { time: unixSeconds, value: number }letv=100;constid=setInterval(()=>{v+=(Math.random()-0.5)*2;setValue(v);setData((prev)=>[...prev,{time: Date.now()/1000,value: v}]);},500);return()=>clearInterval(id);},[]);return(<Viewstyle={{height: 300}}><Livelinedata={data}value={value}color="#3b82f6"theme="dark"/></View>);}

The component fills its parent container — set a height on the parent. Pass data as a growing array of points and value as the latest number; Liveline handles smooth interpolation between updates.

Props

Data

PropTypeDefaultDescription
dataLivelinePoint[]requiredArray of { time, value } points
valuenumberrequiredLatest value (smoothly interpolated)

Appearance

PropTypeDefaultDescription
theme'light' | 'dark''dark'Color scheme
colorstring'#3b82f6'Accent color — all palette colors derived from this. Accepts #rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), or rgba(). Named CSS colors (e.g. "red") are not supported and fall back to grey.
gridbooleantrueY-axis grid lines + labels
badgebooleantrueValue pill tracking chart tip
badgeVariant'default' | 'minimal''default'Badge style: accent-colored or white with grey text
badgeTailbooleantruePointed tail on badge pill
fillbooleantrueGradient under the curve
pulsebooleantruePulsing ring on live dot
lineWidthnumber2Stroke width of the main line in pixels

Features

PropTypeDefaultDescription
momentumboolean | MomentumtrueDot glow + arrows. true = auto-detect, or 'up' | 'down' | 'flat'
scrubbooleantrueCrosshair scrubbing on touch-drag
scrubActivationDelaynumber0Ms of long-press before the scrub pan activates. 0 = immediate. Set e.g. 300 when the chart is embedded in a ScrollView/FlatList so flick-scrolls aren't stolen by the crosshair on first touch
activebooleantrueSuspends the per-frame UI-thread callback entirely when false — wire to list viewability so off-screen charts cost nothing. See Charts in lists below
exaggeratebooleanfalseTight Y-axis — small moves fill chart height
showValuebooleanfalseLarge live value overlay (60fps, no re-renders)
valueMomentumColorbooleanfalseColor the value text green/red by momentum
degenboolean | DegenOptionsfalseBurst particles + chart shake on momentum swings

Candlestick

PropTypeDefaultDescription
mode'line' | 'candle''line'Chart type
candlesCandlePoint[]OHLC candle data { time, open, high, low, close }
candleWidthnumberSeconds per candle
liveCandleCandlePointCurrent in-progress candle with real-time OHLC
lineModebooleanMorph candles into a line display
lineDataLivelinePoint[]Tick-level data for line mode density
lineValuenumberCurrent tick value for line mode
onModeChange(mode: 'line' | 'candle') => voidCallback for built-in line/candle toggle

When mode="candle", pass candles (committed OHLC bars) and liveCandle (the current bar, updated every tick). candleWidth sets the time bucket in seconds. The lineMode prop smoothly morphs between candle and line views — candle bodies collapse to close price, then the line extends outward. Provide lineData and lineValue (tick-level resolution) for a smooth density transition during the morph. The onModeChange prop renders a built-in line/candle toggle next to the time window buttons.

Multi-series

PropTypeDefaultDescription
seriesLivelineSeries[]Multiple overlapping lines { id, data, value, color, label? }
onSeriesToggle(id: string, visible: boolean) => voidCallback when a series is toggled via built-in chips
seriesToggleCompactbooleanfalseShow only colored dots in toggle (no text labels)

Pass series instead of data/value to draw multiple lines sharing the same axes. Each series gets its own color, label, and endpoint dot. Toggle chips appear automatically when there are 2+ series — tapping one hides/shows that line with a smooth fade. The Y-axis range adjusts when series are hidden. Badge, momentum arrows, and fill are disabled in multi-series mode.

State

PropTypeDefaultDescription
loadingbooleanfalseBreathing line animation — use while waiting for data
pausedbooleanfalseSmoothly freeze chart scrolling; resume catches up to real time
emptyTextstring'No data to display'Text shown in the empty state

When loading flips to false with data present, the loading line morphs into the actual chart shape. In line mode, the fill, grid, and badge animate in. In candle mode, flat lines expand into full OHLC bodies while the morph line fades out. When data is empty and loading is false, a minimal "No data" empty state is shown.

Time

PropTypeDefaultDescription
windownumber30Visible time window in seconds
windowsWindowOption[]Time horizon buttons [{ label, secs }]
onWindowChange(secs: number) => voidCalled when a window button is pressed
windowStyle'default' | 'rounded' | 'text''default'Window button visual style

Crosshair

PropTypeDefaultDescription
tooltipYnumber14Vertical offset for crosshair tooltip text
tooltipOutlinebooleantrueStroke outline on tooltip text for readability

Orderbook

PropTypeDefaultDescription
orderbookOrderbookDataBid/ask depth stream { bids, asks }

Fonts

PropTypeDefaultDescription
fontsPartial<LivelineFonts>platform monospaceOverride the Skia fonts used for chart text (see Fonts below)

Advanced

PropTypeDefaultDescription
referenceLineReferenceLineHorizontal reference line { value, label? }
formatValue(v: number) => string (worklet)v.toFixed(2)Value label formatter
formatTime(t: number) => string (worklet)HH:MM:SSTime axis formatter
lerpSpeednumber0.08Interpolation speed (0–1)
paddingPadding{ top: 12, right: auto, bottom: 28, left: 12 }Chart padding override (right is 80/54/12 based on badge/grid)
onHover(point: HoverPoint | null) => voidHover callback with { time, value, x, y }
styleStyleProp<ViewStyle>Container style

Accessibility

PropTypeDefaultDescription
accessibilityLabelstring'Live chart'Label read by VoiceOver/TalkBack for the chart
testIDstringTest id on the chart container; controls derive ids from it (see below)

Accessibility

The chart is drawn with Skia, so without help a screen reader finds nothing but an unlabelled blank region. Liveline announces itself as an image (React Native has no chart role) with accessibilityLabel, and exposes the live number through accessibilityValue — formatted with your formatValue, with the momentum direction appended:

"BTC/USD, image. 64,201.55, rising"

This costs nothing when no screen reader is running. Liveline checks AccessibilityInfo.isScreenReaderEnabled() and subscribes to screenReaderChanged; while no reader is active there is no sampling timer, no state, and no accessibility value at all. When a reader is active the value is sampled once a second — a screen reader cannot follow 60 updates a second, and restarts its utterance on every change, so a per-frame feed would read as an endless stutter. Readings that format identically are skipped, so a still chart stays quiet.

Note that formatValue is called on the JS thread (about once a second) while a screen reader is running, in addition to its usual per-frame call on the UI thread. Keep it free of UI-thread-only dependencies.

The window pills, line/candle toggle and series chips carry their own labels and selected/checked state. The icon-only mode toggle reads as "Line chart" and "Candlestick chart".

Test ids. Give the chart a testID and the built-in controls derive theirs from it, so Detox and Maestro can drive them:

ElementTest id
Chart container${testID}
Window pill${testID}-window-${secs}
Mode toggle${testID}-mode-line, ${testID}-mode-candle
Series chip${testID}-series-${id}

Charts in lists

Each Liveline runs its own 60fps UI-thread frame loop, so a long list of ticker rows keeps one loop running per row — including rows scrolled off-screen. Set active={false} on rows outside the viewport (via your list's viewability callback) to suspend those loops entirely at no cost:

constviewabilityConfig={itemVisiblePercentThreshold: 0};functionTickerList({ rows }: {rows: Ticker[]}){const[visibleIds,setVisibleIds]=useState<Set<string>>(newSet());constonViewableItemsChanged=useRef(({ viewableItems }: {viewableItems: {key: string}[]})=>{setVisibleIds(newSet(viewableItems.map((v)=>v.key)));}).current;return(<FlatListdata={rows}keyExtractor={(row)=>row.id}viewabilityConfig={viewabilityConfig}onViewableItemsChanged={onViewableItemsChanged}renderItem={({ item })=>(<Livelinedata={item.data}value={item.value}active={visibleIds.has(item.id)}/>)}/>);}

Hoist object and function props

Liveline is wrapped in React.memo, and its props are compared shallowly. A new object or function identity on every parent render defeats that — and it costs more than a wasted render. The engine mirrors its config into a shared value on every commit, and the frame loop's idle detection keys off that config's object identity, so a chart that receives fresh props each render can never go idle even when nothing about it has changed.

This matters most in exactly the list above, where one row's tick re-renders the whole list.

// ✗ new identity every render — memo always misses, chart never idles<Livelinedata={item.data}padding={{top: 8,right: 60,bottom: 24,left: 8}}windows={[{secs: 30,label: '30s'},{secs: 60,label: '1m'}]}formatValue={(v)=>`$${v.toFixed(2)}`}/>// ✓ hoisted to module scope (or useMemo / useCallback if they depend on props)constPADDING={top: 8,right: 60,bottom: 24,left: 8};constWINDOWS=[{secs: 30,label: '30s'},{secs: 60,label: '1m'}];constformatUsd=(v: number)=>{'worklet';return`$${v.toFixed(2)}`;};<Livelinedata={item.data}padding={PADDING}windows={WINDOWS}formatValue={formatUsd}/>

data and value are expected to change — that is the live feed, and the engine diffs data rather than re-sending it. It is the configuration props that should be stable.

Note that formatValue / formatTime run on the UI thread and need the 'worklet' directive, which also makes hoisting them the natural choice.

LivelineTransition

Cross-fades between chart components (e.g. line ↔ candlestick). Children must have unique key props matching possible active values. If active doesn't match any child's key, nothing renders (there is no visible chart until active changes to a valid key) — in development this logs a warning naming the bad value and the keys that are available.

PropTypeDefaultDescription
activestringrequiredKey of the active child to display
childrenReactElement | ReactElement[]requiredChart elements with unique key props
durationnumber300Cross-fade duration in ms
styleStyleProp<ViewStyle>Container style
<LivelineTransitionactive={chartType}><Livelinekey="line"data={data}value={value}/><Livelinekey="candle"mode="candle"candles={candles}candleWidth={5}data={data}value={value}/></LivelineTransition>

Examples

Basic (line + badge)

<Livelinedata={data}value={value}color="#3b82f6"theme="dark"/>

Candlestick (minimal)

<Livelinemode="candle"data={ticks}value={latestTick}candles={candles}candleWidth={60}liveCandle={liveCandle}color="#f7931a"formatValue={(v)=>{'worklet';return`$${v.toFixed(2)}`;}}/>

Crypto-style (momentum + degen + exaggerate)

<Livelinedata={data}value={value}color="#f7931a"exaggeratedegenshowValuevalueMomentumColorformatValue={(v)=>{'worklet';return`$${v.toFixed(2)}`;}}/>

Multi-series (prediction market)

<Livelinedata={[]}value={0}series={[{id: 'yes',data: yesData,value: yesValue,color: '#3b82f6',label: 'Yes'},{id: 'no',data: noData,value: noValue,color: '#ef4444',label: 'No'},]}gridscrubpulsewindowStyle="rounded"formatValue={(v)=>{'worklet';returnv.toFixed(1)+'%';}}onSeriesToggle={(id,visible)=>console.log(id,visible)}windows={[{label: '10s',secs: 10},{label: '30s',secs: 30},{label: '1m',secs: 60},]}/>

Orderbook (orderbook data + particles)

<Livelinedata={data}value={value}color="#f7931a"orderbook={{bids: [[100,2],[99,5]],asks: [[101,3],[102,4]]}}degenshowValue/>

React Native differences from the web version

This port keeps the same SDK shape as liveline but a few props differ because of the native/worklet environment:

  • formatValue / formatTime must be worklets. They run on the UI thread, not the JS thread. Add the 'worklet' directive as the first line of the function:

    <Livelinedata={data}value={value}formatValue={(v)=>{'worklet';return`$${v.toFixed(2)}`;}}/>

    The defaults (v.toFixed(2) and HH:MM:SS) are already worklets.

    They must also be pure functions of their input. Label text is cached per axis tick / grid line and re-computed only when the formatter's identity changes, not every frame — so a formatter that reads anything besides its argument (relative time like "5s ago", a mutable locale/timezone captured by closure) will render stale text. If the formatting rule changes, pass a new function instance to invalidate the cache.

  • style is a React Native ViewStyle, not CSSProperties. There is no className or cursor prop — those were web-only (CSS class + cursor affordance), and don't apply on native.

  • New optional fonts prop (Partial<LivelineFonts>) lets you override the Skia fonts used for chart text (label, value, badge, crosshair, orderbook, empty, refLabel, seriesLabel). Defaults come from matchFont (Menlo on iOS, monospace on Android). Build custom fonts with matchFont from @shopify/react-native-skia:

    import{matchFont}from'@shopify/react-native-skia';import{Liveline}from'@ajfuller/react-native-liveline';constvalueFont=matchFont({fontFamily: 'Menlo',fontSize: 13,fontWeight: '700',});<Livelinedata={data}value={value}fonts={{value: valueFont}}/>;
  • scrub is touch-drag, implemented as a react-native-gesture-handlerPan gesture (the web version uses mouse hover). Requires your app root to be wrapped in GestureHandlerRootView (see Installation above). When scrubActivationDelay is set, the gesture requires a long-press hold before activating (via .activateAfterLongPress), so an outer ScrollView/FlatList keeps its own pan gesture for quick flicks.

  • showValue renders via an animated TextInput (the "ReText" pattern — an Animated.createAnimatedComponent(TextInput) driven by useAnimatedProps from the UI thread), instead of a DOM node updated imperatively.

How it works

  • UI-thread engine — the per-frame render step runs inside a Reanimated useFrameCallback worklet, not React state/render.
  • SkPicture per frame — each frame records draw commands into a Skia PictureRecorder and hands the resulting picture to <Canvas><Picture /></Canvas>, so nothing round-trips through the JS thread or bridge.
  • Survives a blocked JS thread — because layout, config, and drawing all live in shared values and worklets, the chart keeps animating smoothly even while your JS thread is busy (e.g. a long synchronous computation, a slow screen transition, or a stress test while loop).
  • Canvas2D-shaped draw layer — the draw code is ported from the web version against a worklet-safe CanvasRenderingContext2D-shaped shim over SkCanvas, preserving the original curve-fitting, layout, and animation logic almost verbatim.

Credits

This library is a React Native port of liveline, created by Benji Taylor. The original web library did the hard work: the chart design, the curve fitting and layout math, the momentum/degen animation feel, the badge and crosshair interactions, and the candlestick line-morph. This port keeps that design and behavior intact — most of the draw and engine code here is adapted near-verbatim from Benji's implementation, retargeted from Canvas2D/DOM to Reanimated worklets and Skia so it can run on the UI thread natively.

If you find this useful, go star the original project.

License

MIT. Original work © 2025-2026 Benji Taylor; React Native port © 2026 Andrew Fuller. See LICENSE.

About

Real-time animated charts for React Native. Line, multi-series, and candlestick modes, Skia-rendered, 60fps.

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages