High-performance, natively rendered graphs for web, iOS, and Android, designed for developers and coding agents.
Installation
One package covers every platform. There is nothing else to add for iOS or Android — the native modules ship inside it.
npm install @hzblj/zyplot
No stylesheet import. Zyplot includes its compiled styles through the JavaScript entry point. Your application does not need Tailwind CSS.
Entry points
A plain @hzblj/zyplot import resolves to the renderer the current target needs, and gives you the chart forms that exist everywhere. The three subpaths name a platform outright, and are how you reach forms only one renderer has.
Prop
Type
Default
Description
@hzblj/zyplot
any target
—
Resolves per target: ECharts and uPlot on the web, the native module under React Native. Exposes the twenty-one shared forms.
@hzblj/zyplot/web
web
—
The DOM renderer, and the only entry with Chart.Frame, Chart.Legend and a .Skeleton on every form.
@hzblj/zyplot/ios
iOS
—
Shared forms plus Chart.Range and Chart.Rule, and the Swift Charts scrolling options on xAxis.
@hzblj/zyplot/android
Android
—
Shared forms plus Chart.Lollipop and Chart.Waterfall, and Compose label overflow on both axes.
All four carry the builders and useLastReading, so a helper that assembles chart props is not tied to a platform. useChartScrub is on all four: a finger on the native entries, a pointer on the web one, reported as the same phases.
Web
The web renderer
@hzblj/zyplot/web draws in the DOM. A plain @hzblj/zyplot import already resolves here on every target except React Native, so naming the subpath only matters in a project that has both. It is also the only entry point that carries Chart.Frame, Chart.Legend and a .Skeleton on every form — all three are DOM composition with no native counterpart. Chart.Provider exists on every entry point, but only here does it take colorMode and a className.
What draws what
Component names are the form, not the engine. Chart.Line and Chart.TimeSeries both draw a line — you pick between them on point count, never on renderer.
Prop
Type
Default
Description
ECharts
canvas
—
Eighteen of the twenty-one forms, Line through Treemap. Series types are registered per chart file, so a page with one line chart does not ship the sankey, sunburst and boxplot code.
uPlot
canvas
—
Chart.TimeSeries and Chart.Sparkline. Tens of thousands of points, or forty sparklines in a table, where one scene graph per row would drop frames.
Plain DOM
no engine
—
Chart.Meter is a filled track — two elements, role="meter" and no engine, so it is the only form whose final markup is painted on the server instead of after a first read off the document.
Text stays in the DOM
Only marks and axis ticks are painted into the canvas. The legend is React: every chart with two or more series renders one on its own, a single series never does, and the labels stay selectable, translatable and reachable by a screen reader.
Server components
Chart props stay serializable — no formatter callbacks, no render props — so a server component can render a chart without a client boundary of its own. Anything that would have been a callback is expressed as data instead, which is what ChartNumberFormat is for.
Colors are read off the document. A canvas takes color as a string, so each chart reads the resolved --zyplot-* values from the DOM after mounting and repaints when they change. Until that first read there is nothing honest to paint, so a chart shows its skeleton for a frame even with isLoading false.
Native
iOS and Android
Zyplot ships an Expo module that draws with the platform's own graphics stack — Swift Charts on iOS, Jetpack Compose Canvas on Android — behind the same Chart namespace the web renderer exposes. There is no WebView.
Installation
The same single package you installed for the web carries the native code; autolinking picks it up. Rebuild the native project after adding it. These are native modules, so Expo Go cannot load them: use a development build.
iOS 17 and newer. Swift Charts features used by the renderer require a deployment target of 17.0. Set it with expo-build-properties if your app targets something lower.
Import from @hzblj/zyplot and the correct renderer is picked per platform — the same source builds for web, iOS and Android.
A few forms exist on one platform only, because the underlying renderer has a mark the other has no honest equivalent for. They are not on the shared namespace — reaching for one would type-check and then render nothing on the platform that lacks it.
Instead, give the component one file per platform and let Metro choose. Write the shared parts once, import the platform entry point in the file that is already committed to that platform, and drop the Platform.OS branches entirely.
text
forecast.ios.tsx imports @hzblj/zyplot/iosforecast.android.tsx imports @hzblj/zyplot/androidforecast.tsx optional web or fallback version
The call site imports ./forecast with no extension and never learns which one it got. This is the same convention Expo's own UI packages use.
Keep a base file for TypeScript.tsc does not know about platform extensions, so ./forecast needs a plain forecast.tsx to resolve to. It doubles as the web version, or returns null if the component is native-only.
Chart coverage
All twenty-one shared chart kinds render on both native platforms, and each platform adds two of its own. Most take exactly the props documented under Charts; eleven differ, and they are listed below the coverage table.
Prop
Type
Default
Description
Cartesian
web · iOS · Android
—
Line, Area, Bar, StackedBar, TimeSeries, Sparkline, Scatter, Histogram.
Chart.Range and Chart.Rule, built on Swift Charts marks with no web equivalent.
Android extensions
Android only
—
Chart.Waterfall and Chart.Lollipop, with no web equivalent.
Where a form's data prop is named differently, or a web-only option has no native counterpart, the compiler says so — these are separate prop types, not one type with fields the native renderer drops.
Prop
Type
Default
Description
Chart.Funnel
stages → data
—
The stages are data on native. Same ChartDatum[], same order.
Chart.Sunburst · Chart.Treemap
nodes → data
—
The hierarchy is data on both forms natively.
Chart.Pie
innerRadius
—
Native takes innerRadius, a fraction that hollows the middle, instead of the web isSolid, maxSlices and otherLabel. Fold the tail yourself before passing the data.
Chart.Gauge
label, optional max
—
Native adds label, and max is optional there — it defaults to 100.
Chart.Meter
gauge props
—
Native takes the gauge props — value, max, min, label — so there is no showValue, and label is optional.
Chart.Dumbbell
no beforeLabel / afterLabel
—
Native takes rows alone; label the two ends in your own view. Both platforms read the pair off each row.
Chart.Histogram
no valueFormat
—
Use format for the bin boundaries on native — it is on every native chart.
Chart.DivergingBar
no orientation
—
Bars always run horizontally on native.
Chart.Sparkline
no slot
—
Native takes an explicit color instead of a palette slot.
Chart.Candlestick
adds labels
—
Native takes labels — your five words for open, high, low, close and change — and the tooltip then lists every reading instead of just the close.
The presentation vocabulary
Five of the option groups take a wider shape than the plain contract: seriesStyles, animation, interaction, annotations and the two axes. The wider shapes are the Native* types, they are supersets, and they are what every renderer takes — the web one included. A glow, a traced entrance, a selection marker and a pulsing point are all drawn in the DOM too.
Adds the overlay position, labelInset, labelSize, ticks and the two plot-dimension paddings. Listed below under axis options.
The names are historical. The vocabulary was built for the platform graphics stacks and landed on the web renderer afterwards, so the types still read Native*. Four fields are still native alone: interaction.haptics, which the web has no honest equivalent for, style.candleRadius, which the web candlestick engine cannot round, and style.neutralColor and style.volumeHeightRatio — the web candlestick colours a flat session as a rising one and gives the volume histogram a fixed share of the plot.
A decorated point is drawn by the layer that reads the pointer. So glow, halo, pulse, badge and size on an annotation land on Chart.Line and Chart.Candlestick on every platform, and on the rest of the cartesian forms on iOS and Android. On a web Chart.Area, Chart.Bar or Chart.StackedBar a point annotation is a plain mark and a rule carries no badge — the rules, ranges, labels and labelBackground those forms do draw are the engine's own, and annotationViews works on all five, so your own node is the way to put a head on a mark there.
tsx
type ChartGlow = { /** Defaults to the mark’s own colour. */ color?: string opacity?: number radius?: number}/** A hard disc behind a point. Unlike a glow it has an edge. */type ChartHalo = { color?: string opacity?: number size?: number}type ChartPulse = { /** Defaults to the glow's colour, then to the point's own. */ color?: string /** One bloom, in ms. Default 450. */ duration?: number /** The rest between blooms, in ms. Default 1550. */ interval?: number /** How opaque the ring starts, before fading out over the bloom. Default 0.9. */ opacity?: number /** How far it blooms, as a multiple of the point's resting ring. Default 2.2. */ scale?: number}type ChartRevealAnimation = { /** "draw" | "fade" | "none" */ style?: ChartRevealStyle duration?: number /** "ease-in" | "ease-in-out" | "ease-out" | "linear". Defaults to "linear" for a trace. */ easing?: ChartRevealEasing /** A brief brightening as the trace lands. "draw" only. */ flashColor?: string flashDuration?: number /** The curve the flash decays along once it has held. Defaults to "ease-out". */ flashEasing?: ChartRevealEasing /** How far the glow blooms at the peak, as a multiple of its resting radius. */ flashGlow?: number /** How long the flash holds at full strength before decaying. */ flashHold?: number flashOpacity?: number /** How dim the stroke starts while being traced, as a fraction of its final opacity. */ startOpacity?: number /** Draws the whole path in this colour under the trace, so the shape reads from frame one. */ trackColor?: string trackOpacity?: number}/** How marks move when the data changes under a mounted chart. */type ChartTransition = 'crossfade' | 'morph'
crossfade is the honest one when the axis changed.morph interpolates between the two datasets, which reads as the data moving. If the categories underneath are not the same categories, nothing moved — dissolve instead.
These are the fields the builders exist for. reveal.draw and reveal.fade accept only the fields their own style reads, and glow and halo give the nested objects a name you can declare once and reuse.
Scrubbing
Three fields carry a scrub: index, the mark's position in the data you passed, phase, where the gesture is in its lifetime, and geometry. Together they are what lets a readout outside the plot follow a finger and then return to rest. One phase is not a gesture at all: 'layout' fires once the chart has measured itself and carries the geometry, which is what you position your own views over the plot with. All three are reported on the web too, from the pointer.
Every coordinate in an event — geometry and the pointer's nativeX/nativeY — is in the unit the platform lays views out in: points on iOS, dp on Android, px on the web. So a view positioned from one lands where the chart drew, with no density arithmetic in between.
tsx
type ChartInteractionPhase = 'began' | 'changed' | 'ended' | 'layout'type ChartInteractionEvent = { /** Position of the selected mark in the chart’s own data order. */ index?: number phase?: ChartInteractionPhase /** Where the plot and its annotations sit, on the "layout" phase. */ geometry?: ChartGeometry // … plus category, value, seriesId and the pointer position}type ChartGeometry = { annotations: readonly { id: string; x: number; y: number }[] plot: { height: number; width: number; x: number; y: number }}/** How the mark under the finger is picked out. */type ChartSelectionMarker = { /** "point" | "segment" */ style?: ChartMarkerStyle color?: string glow?: ChartGlow /** A dot’s diameter. "point" only. */ size?: number /** How many data steps either side of the touch a segment covers. "segment" only. */ span?: number}type ChartCrosshairStyle = { color?: string dash?: number[] width?: number}
Prop
Type
Default
Description
marker.style
"point" | "segment"
"point"
A dot on the mark, or a lit stretch of the line around it.
marker.size
number
9
Dot diameter, in points.
marker.span
number
2
Data steps either side of the touch.
glow.opacity
number
0.55
Glow opacity at rest.
glow.radius
number
6
Glow radius, in points.
halo.size
number
12
Halo diameter, in points.
crosshairStyle.width
number
1
Crosshair line width.
annotation.pulse
boolean | ChartPulse
false
A ring blooming out of a point annotation and resting before it does it again, to mark "now". true takes 450 ms out, 1550 ms at rest, 2.2× the point’s ring.
annotation.labelBackground
string
—
Painted behind an annotation’s label, so its value stays legible where the marks run through it.
Which side of the rule its label sits on. "auto" keeps it inside the plot: above a rule sitting low, below one sitting high. Omit it and each renderer keeps its own default side, so name one when the three have to agree.
interaction.highlightColor
string
—
A colour the mark under the finger is lifted towards, so the read one reads as lit.
interaction.highlightBlend
number
1
How far towards it. Below 1 the mark’s own colour still reads through the lift.
style.candleRadius
number
0
Corner radius on a candle body. Rounds the wick’s caps with it.
annotation.scrubOpacity
number
1
What an annotation fades to while a point is being read. Set 0 to hide it entirely.
annotation.badge
string
—
A single glyph in a filled circle capping a rule at the plot edge; the rule starts below it. Leave it off and the chart draws only the rule, so your own view can sit there instead.
annotation.size
number
—
A point annotation’s dot diameter, or a badge circle’s. Each renderer rests at a slightly different dot size, so name it when the three have to agree; a badge takes it on iOS and the web, and Android draws a fixed one.
annotationViews
Record<string, ReactNode>
—
Your own node where an annotation lands, keyed by its id. The chart centres it on the spot and leaves out the mark it draws there itself.
annotation.hidden
boolean
false
Measured and reported in geometry as usual, but drawn by nobody — for an annotation that is only an anchor for a view of your own.
Reach for useChartScrub before writing a handler. It already turns these events into a selection that clears itself on 'ended', which is the whole of what a readout above the plot needs.
Native axis options
xAxis and yAxis take everything ChartAxisOptions defines, plus the controls below on both platforms. Each renderer then adds its own — see iOS and Android.
Prop
Type
Default
Description
position
"start" | "end" | "overlay"
"start"
overlay puts the labels inside the plot against its trailing edge and reserves no gutter, keeping the full width for the marks. Build it with axis.overlay.
labelInset
number
2
How far an overlaid label sits from the plot’s trailing edge. Only read when position is overlay.
labelSize
number
11
Point size of the tick labels.
ticks
boolean
true
Draws the short marks beside each label. Independent of grid, and coloured by theme.colors.axis, which is the only thing either renderer draws with it — neither Swift Charts nor a Compose canvas draws a domain line.
plotDimensionStartPadding
number
0
Free space, in points, kept before the first mark.
plotDimensionEndPadding
number
0
Free space, in points, kept after the last mark.
Differences from web
Prop
Type
Default
Description
className
web only
—
Native charts have no DOM node to style. Use height and plot instead.
texture
web only
—
Pattern fills answer forced-colors and print, which a native chart never meets. Not part of the native props — the compiler rejects it rather than the renderer ignoring it.
skeleton
web only
—
Both native renderers draw a shimmering placeholder matched to the form — a ring, a column row or a curve — but there is no slot to put your own in, and no .Skeleton component to mount on its own.
annotationViews
wider on native
—
On every native form’s props, and it lands on the ones that anchor an annotation to a plot — the cartesian forms. On the web it is Chart.Line, Chart.Area, Chart.Bar, Chart.StackedBar and Chart.Candlestick, the forms that report where their annotations landed.
colorMode
native chart · web provider
"system"
Per chart on native: there is no cascade to inherit through, so Chart.Provider scopes surface and theme but not the color mode. On the web it is the provider that takes it, and it has an "inherit" the native prop does not.
accessibilityLabel
native only
—
The accessibility name of the chart view, since there is no DOM node to label. On the web, use className and the surrounding markup.
format
every native form
—
On the base props natively, so every form takes it. On the web only the forms that write numbers do, and a few take a second one — valueFormat on the histogram, xFormat and yFormat on the scatter.
height
different default
320
Points on native, px on the web, where it defaults to 240 — and to 200 for the gauge and 300 for the sankey.
labels
wider on native
—
Boxplot terminology is honoured natively too. Chart.Candlestick takes labels only here — pass your five words and the tooltip lists every reading instead of just the close.
The presentation props are not on this list.glow, reveal, marker, badge, pulse, halo and the overlaid axis all render in the DOM as well — see the presentation vocabulary above. Only interaction.haptics, style.candleRadius, style.neutralColor and style.volumeHeightRatio are native alone. Eleven forms do take different data props, though: those are in chart coverage.
@hzblj/zyplot/ios renders with SwiftUI and Swift Charts. Chart configuration crosses the bridge as JSON and is decoded into native models, so every prop stays serializable. Importing it commits a file to iOS, so name that file *.ios.tsx.
Two marks Swift Charts has that neither other renderer does.
Web not supportediOS supportedAndroid not supported
Prop
Type
Default
Description
Chart.Range*
ChartRangePropsIos
—
Shaded band between a low and a high per category — forecast ranges, confidence intervals.
Chart.Rule*
ChartRulePropsIos
—
Reference rules at a value, optionally spanning a start and end.
iOS axis options
xAxis and yAxis accept everything the shared native axis defines — the overlay position, labelInset, labelSize, ticks and the two plotDimension paddings among them — plus these Swift Charts scrolling controls, which belong to the x axis alone.
Prop
Type
Default
Description
xAxis.visibleDomain
number
—
Length of the visible x domain. Setting it makes the plot horizontally scrollable.
xAxis.scrollPosition
number | string
—
Initial scroll offset along the x axis.
Native
Android
@hzblj/zyplot/android draws on a Jetpack Compose Canvas. Marks, axis text, grid, annotations and the tooltip are all drawn by the module, so a chart is one view rather than a tree of them. Importing it commits a file to Android, so name that file *.android.tsx.
Two forms the Compose Canvas draws that neither other renderer does.
Web not supportediOS not supportedAndroid supported
Prop
Type
Default
Description
Chart.Waterfall*
ChartWaterfallPropsAndroid
—
Running total across signed movements, colored by direction.
Chart.Lollipop*
ChartLollipopPropsAndroid
—
A stem and a dot per category — a bar chart with the ink of a dot plot.
Android axis options
xAxis and yAxis accept everything the shared native axis defines, plus overflow handling for long tick labels — the one control Compose needs that Swift Charts resolves on its own.
Prop
Type
Default
Description
xAxis.labelOverflow
"clip" | "ellipsis" | "visible"
"ellipsis"
How a tick label that exceeds its band is cut.
yAxis.labelOverflow
"clip" | "ellipsis" | "visible"
"ellipsis"
How a tick label that exceeds the gutter is cut.
Theming
Chart.Provider scopes a palette to a subtree. It writes what you pass as --zyplot-* custom properties on its own wrapper, and the charts inside read the resolved values back off the DOM. A key you leave out keeps the stylesheet's value, including that value's dark variant.
A theme key holds in both modes. The provider writes inline custom properties, which outrank the stylesheet's light and dark rules alike — so a color passed here is the color in both. When a palette has to change with the mode, set it in CSS instead.
Every key is optional and takes any color the browser can resolve. There is no background here — the box a chart sits in is surface, below.
The provider's shape is ChartProviderTheme. It is the full palette, because the provider writes CSS variables and the stylesheet has one for each of these. It is also a superset of the ChartTheme a single chart takes, so one object can be passed to both — see below.
tsx
type ChartProviderTheme = { colors?: { /** Slots 1…7, in order. A series takes one by index or by its own slot. */ categorical?: readonly string[] /** Low → high, five steps. Heatmap, treemap, sunburst. */ sequential?: readonly string[] /** A signed scale in full. The flat negative/positive below are its shorthand. */ diverging?: { negative?: string negativeSoft?: string neutral?: string positive?: string positiveSoft?: string } /** The de-emphasis grey — every series that is context rather than subject. */ muted?: string axis?: string grid?: string /** Axis and data labels. */ label?: string negative?: string positive?: string /** Tooltip fill. */ surface?: string /** Tooltip hairline. */ border?: string /** The unfilled part of a gauge or a meter. */ track?: string } typography?: { /** A resolved family name. A canvas cannot read var(--font-sans). */ fontFamily?: string }}
Seven and five. Only the first seven categorical entries and the first five sequential steps are ever read. An eighth series color is one no color-blind reader can separate from a slot that already exists, so the eighth series is an "other" bucket, a small multiple, or a second encoding through texture — not a longer palette.
A theme on one chart
Every chart also takes a theme of its own, on all four entry points. This one is ChartTheme, and it is the portable subset: every key on it is a key each of the four renderers draws with, so a theme written against it works unchanged on the web, on iOS and on Android.
How it meets the provider's is not the same on both. On the web the provider's keys are CSS variables the chart has already resolved, so a chart's own theme lands over them key by key — one colour here does not drop the rest. On native there are no variables to resolve against: a chart that passes a theme replaces the provider's outright, so restate the keys you still want, or leave the theme to the provider and style the chart with seriesStyles and surface instead. surface does merge key by key on both.
tsx
import type { ChartTheme } from '@hzblj/zyplot'type ChartTheme = { colors?: ChartThemeColors typography?: ChartTypography}type ChartThemeColors = { axis?: string /** Series colours, in the order series take them. */ categorical?: readonly string[] grid?: string label?: string /** The negative half of a signed scale: a losing bar, a falling candle. */ negative?: string positive?: string /** Tooltip fill. */ surface?: string /** The unfilled part of a gauge or a meter. */ track?: string}/** The one colour only a native chart paints for itself. */type NativeChartTheme = { colors?: ChartThemeColors & { background?: string } typography?: ChartTypography}
The two wider shapes build on that subset rather than beside it. ChartProviderTheme adds the palettes and greys only a CSS variable can carry; NativeChartTheme, which the native charts and the native provider take, adds the chart's own background. Both are supersets, so one ChartTheme value satisfies all three props.
A chart background is a surface, not a theme, on the web. There is no --zyplot-color-background: in the DOM the box a chart sits on is surface.background, which exists on native too. Reach for that when one object has to dress a chart on every platform.
typography.fontFamily takes a resolved family name. Each platform looks it up the way its own text does: the DOM through --zyplot-font-family, iOS through the registered-font lookup behind UIFont(name:), Android through React Native's font manager — which covers assets/fonts, res/font and anything expo-font loaded at runtime. So a family that works in a <Text> works in a chart. One the app never shipped falls back to the platform font on all three.
The chart surface
theme answers "what colour is this series"; surface answers "what does the container look like". Keeping them apart is what lets a design system set one card treatment for every chart while each chart keeps its own palette.
A number applies to all four sides; the object form takes horizontal, vertical and the individual sides, most specific winning.
The same four keys everywhere. Only properties that mean the same thing to a div, a SwiftUI view and a Compose Canvas live here. Anything that would have to be approximated on one of the three is deliberately absent.
Container treatment every chart in the subtree inherits, merged key by key with the chart's own.
colorMode
"inherit" | "light" | "dark" | "system"
"inherit"
How the light/dark palette is resolved for the subtree.
className
string
—
CSS class on the wrapper element the provider renders.
The provider renders an element. The custom properties have to land somewhere, so the scope is a real div in your layout rather than context alone. It carries className for that reason.
Light and dark mode
Both palettes ship in the stylesheet. The dark one is keyed off .dark or data-theme="dark" on the document root — the two conventions Tailwind and next-themes already write — and a root that pins neither falls back to prefers-color-scheme. A project doing either needs no chart-specific wiring.
Resolution
Chart.Provider pins a subtree instead. Its colorMode lands as data-zyplot-color-mode on the wrapper, and the stylesheet resolves the palette from there.
Prop
Type
Default
Description
"inherit"
document root
default
Takes whatever the document root resolved to, including the OS fallback. Charts outside a provider behave this way too.
"light"
pinned
—
The light palette regardless of the root, plus color-scheme: light.
"dark"
pinned
—
The dark palette regardless of the root, plus color-scheme: dark.
"system"
media query
—
Follows the OS through prefers-color-scheme, ignoring the document root.
Switching repaints in place. Because canvas colors are read off the DOM, every mounted chart watches class, data-theme, data-zyplot-color-mode and style on the root — plus the prefers-color-scheme query — and repaints from the new values. No remount, and no chart left painting light-mode series on a dark canvas.
The CSS contract
These names are the public API; the Tailwind tokens behind them are not. Override them wherever you set the rest of your theme — the values below are the light defaults, and every color among them has a dark counterpart in the stylesheet.
css
:root { /* Categorical: slots 1…7, in the order series take them. */ --zyplot-color-categorical-1: #4400fc; --zyplot-color-categorical-2: #0092de; --zyplot-color-categorical-3: #ff5700; --zyplot-color-categorical-4: #9c74ff; --zyplot-color-categorical-5: #00a546; --zyplot-color-categorical-6: #006fac; --zyplot-color-categorical-7: #ff133c; /* Sequential: low → high. Heatmap, treemap, sunburst. */ --zyplot-color-sequential-1: #b89bff; --zyplot-color-sequential-2: #9c74ff; --zyplot-color-sequential-3: #7135ff; --zyplot-color-sequential-4: #4400fc; --zyplot-color-sequential-5: #2f00ae; /* Diverging: signed scales. */ --zyplot-color-diverging-negative: #d23100; --zyplot-color-diverging-negative-soft: #ff7d4f; --zyplot-color-diverging-neutral: #d9d9d9; --zyplot-color-diverging-positive-soft: #59c4fd; --zyplot-color-diverging-positive: #006fac; /* Chrome. */ --zyplot-color-axis: #a6a6a6; --zyplot-color-grid: #f5f5f5; --zyplot-color-label: #666666; --zyplot-color-muted: #808080; --zyplot-color-surface: #fcfcfc; --zyplot-color-border: #f5f5f5; --zyplot-color-track: #ebebeb; --zyplot-font-family: inherit;}/* Only the keys you actually change need restating per mode. */[data-theme='dark'] { --zyplot-color-categorical-1: #7135ff; --zyplot-color-grid: #212121;}
The font is inherited from the page. Set --zyplot-font-family only when charts should use a different stack, and give it a resolved family name — a canvas cannot read another variable.
A page with no font of its own gets a system stack. Inheritance is the mechanism, so when nothing up the tree declares a font the browser answers with its own serif, and a chart has no business painting Times beside text that is not. Zyplot compares what it inherited against that untouched default and falls back to system-ui and the platform stack behind it. React Native Web is the case this exists for: its reset puts no font on the document and styles each <Text> on its own, so a chart has nothing to inherit however deeply it looks — with the fallback it matches the text beside it instead.
Wide-gamut values are safe. On a P3 display these resolve to color(display-p3 …), which ECharts' own parser rejects. Every color is normalized to sRGB on the way to the canvas, so the variable can hold whatever your design tokens hold.
Data
Chart data is plain serializable objects — no formatter callbacks and no render props, which is what lets a server component render a chart. Labels are already translated: this package never resolves an i18n key. Type names in the props tables link here.
One contract, and every list is read-only. These types live in the shared contract and are re-exported by all four entry points, so a value typed once can be handed to a web chart and a native one. Every prop that takes a list — series, categories, data, nodes, cells, groups, rows, values — accepts a readonly array, so an as const value or a readonly-returning selector needs no cast. The shapes below are written as plain arrays for legibility.
ChartSeries
Used by line, area, bar, stacked bar, radar and every other multi-series form. Each values entry aligns with the category at the same index, so the array is as long as categories.
tsx
type ChartSeries = { /** Stable identity: the React key, and how hover correlates across charts. */ id: string /** Already-translated display name, used by the legend and the tooltip. */ label: string /** One value per category. null is a genuine gap, drawn as one, never as zero. */ values: (number | null)[] /** Palette slot, 1-based. Pin it when the caller can hide series. */ slot?: number /** Overrides the active palette for this series. */ color?: string}
An explicit color wins over the provider palette and the CSS variables. Omit slot and a series takes its index — correct for a fixed list, wrong the moment the list can be filtered, because the survivors get repainted and the reader has to re-learn the chart.
A labelled scalar — the shape part-to-whole and ranked forms consume: pie, funnel, gauge segments, diverging bars.
tsx
type ChartDatum = { id: string label: string value: number slot?: number color?: string}
Axes and number formatting
axis is the on/off switch both cartesian axes share; format is one description of a number, applied to axis ticks, tooltips and direct labels alike, so they can never disagree.
tsx
type ChartAxes = { x?: boolean y?: boolean}type ChartNumberFormat = { /** Fraction digits. Defaults to 0. */ decimals?: number /** BCP 47 tag for grouping and decimal separators. Defaults to the runtime locale. */ locale?: string /** Rendered before the number — a currency symbol, typically. */ prefix?: string /** Rendered after the number — a unit or a percent sign. */ suffix?: string}
ChartLegendItem
What Chart.Legend takes when you place identity yourself. The color is already resolved — a swatch is a color, not a slot to look up.
tsx
type ChartLegendItem = { id: string label: string color: string}
Specialized chart data
Some forms take a shape-specific contract instead of ChartSeries, because their encoding is not "one value per category". The field names describe the marks directly.
tsx
/** Chart.Radar — one axis per row. Axes are scaled independently. */type ChartRadarAxis = { label: string max: number}/** Chart.Heatmap — addressed by axis index; null renders empty, not as the ramp's low end. */type ChartHeatmapCell = { columnIndex: number rowIndex: number value: number | null}/** Chart.Dumbbell — a before → after pair per row. */type ChartDumbbellRow = { id: string label: string before: number after: number}/** Chart.Boxplot — the five-number summary, plus outliers you have already picked. */type ChartBoxplotGroup = { id: string label: string min: number q1: number median: number q3: number max: number outliers?: number[]}/** Required, because "Q1" is not a word every reader of your product knows. */type BoxplotLabels = { min: string q1: string median: string q3: string max: string}/** Chart.Sankey — nodes, and the weighted edges that address them by id. */type ChartFlowNode = { id: string label: string slot?: number color?: string}type ChartFlowLink = { source: string target: string value: number}/** Chart.Treemap and Chart.Sunburst — leaves carry a value, parents sum their children. */type ChartHierarchyNode = { id: string label: string value?: number children?: ChartHierarchyNode[] slot?: number color?: string}/** Chart.Scatter — an unordered two-measure space. size turns points into bubbles. */type ChartScatterSeries = { id: string label: string points: Array<{ x: number y: number size?: number label?: string }> slot?: number color?: string}/** Chart.TimeSeries — parallel arrays, because that is what uPlot consumes. */type ChartTimePoints = { /** Unix seconds, strictly ascending. */ timestamps: number[] /** One entry per series, each as long as timestamps. */ values: (number | null)[][]}
Candlestick data
One entry per session. volume is what showVolume draws, and timestamp is only needed when something outside the chart has to line up with the session.
tsx
type ChartCandlestickDatum = { id: string category: string open: number high: number low: number close: number volume?: number /** Unix seconds. */ timestamp?: number}type ChartCandlestickStyle = { upColor?: string downColor?: string neutralColor?: string /** Draws rising candles as outlines — the convention on most trading desks. */ hollowUp?: boolean /** Corner radius on the candle body. Rounds the wick's caps with it. */ candleRadius?: number /** Body width as a share of the slot a candle sits in. */ candleWidth?: number wickWidth?: number volumeUpColor?: string volumeDownColor?: string /** Share of the plot height the volume histogram takes. */ volumeHeightRatio?: number}
Axis options
axis switches an axis off; xAxis and yAxis describe one. Both are read by line, area, bar, stacked bar and candlestick — the forms whose readers pin a domain, change the scale or annotate a value. The other forms take the visibility switch only, which is why their props tables list axis alone.
tsx
type ChartAxisOptions = { visible?: boolean label?: string /** "auto" | "category" | "linear" | "log" | "time" */ scale?: ChartAxisScale domain?: ChartAxisDomain format?: ChartNumberFormat grid?: boolean gridDash?: number[] labelRotation?: number /** Which side the axis is drawn on: "start" | "end". */ position?: ChartAxisPosition reversed?: boolean /** A hint, not a guarantee — the engine still picks readable ticks. */ tickCount?: number /** Exact ticks, when the reader is looking for specific ones. */ tickValues?: (number | string)[]}type ChartAxisDomain = { /** Pins the extent. Omit either end to keep it computed from the data. */ min?: number max?: number /** * Headroom kept beyond the data, as a fraction of its extent — 0.08 leaves 8% * clear at each end. Applies only to an end that is computed, so it combines * with a single pinned one. */ padding?: number}
Reach for padding before pinning a domain. Without it a line runs into the top and bottom of its own plot, which reads as clipped rather than as the highest and lowest reading. A pinned min/max fixes that too, but it has to be recomputed every time the data changes; a fraction does not.
Annotations
A union discriminated on type. Coordinates are number | string: a category name on a category axis, a value on a linear or time one.
tsx
type ChartAnnotation = | ChartLineAnnotation | ChartRangeAnnotation | ChartPointAnnotation | ChartTextAnnotation/** A target, a threshold, a launch date. */type ChartLineAnnotation = { type: 'line' id: string axis: 'x' | 'y' value: number | string label?: string color?: string /** Dash and gap lengths. Omit for a solid rule. */ dash?: number[] /** Rule thickness. Default 1. */ width?: number}/** A shaded span — a quarter, an incident window, a tolerance band. */type ChartRangeAnnotation = { type: 'range' id: string axis: 'x' | 'y' start: number | string end: number | string label?: string color?: string opacity?: number}type ChartPointAnnotation = { type: 'point' id: string x: number | string y: number label?: string color?: string symbol?: ChartSymbol}type ChartTextAnnotation = { type: 'text' id: string text: string x?: number | string y?: number color?: string}
Interaction
interaction is what the chart does on its own; onInteraction is how your code hears about it. The event is one flat serializable shape for every form, so a handler written for a bar chart works on a line chart.
tsx
type ChartInteraction = { /** "axis" | "nearest" | "series" | "none" */ hover?: ChartHoverMode /** "both" | "x" | "y" | "none" */ crosshair?: ChartCrosshairMode tooltip?: boolean /** "single" | "multiple" | "none" */ selection?: ChartSelectionMode pan?: boolean zoom?: boolean /** How far a hovered mark grows. */ highlightScale?: number /** How far the rest fades while one mark is hovered. */ dimOpacity?: number /** A colour the read mark is lifted towards, so it reads as lit rather than as undimmed. */ highlightColor?: string /** How far towards it, 0–1. Below 1 the mark's own colour still reads. Default 1. */ highlightBlend?: number /** Native only — the web has no honest equivalent. */ haptics?: boolean}type ChartInteractionEvent = { seriesId?: string category?: string value?: number x?: number y?: number /** Unix seconds, on the time-based forms. */ timestamp?: number /** Pointer position in the chart's own coordinate space. */ nativeX?: number nativeY?: number /** Position of the read mark in the chart's own data order. */ index?: number /** "began" | "changed" | "ended" | "layout" */ phase?: ChartInteractionPhase /** Where the plot and its annotations sit, on the "layout" phase. */ geometry?: ChartGeometry}
A handler is a client boundary. Everything else on a chart is serializable data, so a server component can render it — onInteraction is the one prop that cannot cross, and the file that passes it needs "use client".
Plot, series style and animation
surface is the box the chart sits in; plot is the drawing area inside it. seriesStyles is keyed by ChartSeries.id, so a style survives reordering and filtering the way slot does for color.
These three are the floor, not the ceiling.animation, seriesStyles and interaction props are declared with the wider Native* shapes, which add a traced reveal, a glow and a selection marker on top of what is below — on every entry point, the web one included. The same goes for annotations and the two axes.
tsx
type ChartPlotStyle = { backgroundColor?: string borderColor?: string borderWidth?: number borderRadius?: number /** Clips marks to the plot area — the honest choice when a domain is pinned. */ clip?: boolean padding?: number | { top?: number; right?: number; bottom?: number; left?: number }}type ChartSeriesStyle = { color?: string strokeWidth?: number strokeDash?: number[] fillOpacity?: number opacity?: number /** "circle" | "diamond" | "square" | "triangle" | "none" */ symbol?: ChartSymbol symbolSize?: number}type ChartAnimation = { enabled?: boolean /** The entrance. Turn it off for a chart that re-renders on every keystroke. */ initial?: boolean /** The transition when data changes under a mounted chart. */ updates?: boolean duration?: number /** How long the entrance waits before it starts. A beat here keeps a trace from being * drawn under a navigation transition, where its first part is never seen. */ delay?: number /** "linear" | "ease-in" | "ease-out" | "ease-in-out" | "spring" */ easing?: ChartAnimationEasing}
Builders
Every prop on this page is a plain object, and writing one by hand stays supported. The builders exist for the handful of shapes where hand-writing is genuinely error-prone: a union you have to remember the discriminant for, a record whose key repeats an id declared elsewhere, a type that permits two fields only one variant reads.
They return the same plain objects the props always were — no wrapper, no class, nothing to unwrap. So they compose, spread and serialize, and you can build your own presets on top of them.
tsx
import { // Builders with variants, one function per variant. annotation, axis, marker, reveal, // A series and its styling, declared together. series, seriesProps, // Typed passthroughs for the groups with nothing to get wrong. animation, format, glow, halo, interaction, plot, seriesStyle, surface, theme,} from '@hzblj/zyplot'
They are on every entry point. The builders live in the shared contract, so the same import works from @hzblj/zyplot, /web, /ios and /android, and every renderer draws what they describe: glow, halo, badge, pulse and scrubOpacity all land in the DOM as well as on a native canvas.
series and seriesProps
seriesStyles is a record keyed by ChartSeries.id, which means styling a series normally spells its id twice — once on the series, once as the key — with nothing checking the two agree. A typo does not fail: it silently drops the styling. series declares both in one place and seriesProps splits the list into the two props the chart takes.
One series with its styling attached. Everything ChartSeries takes, plus style for the per-series overrides.
seriesProps(list)
{ series, seriesStyles }
—
Splits a list of styled series into { series, seriesStyles }, keying each style by the id it was declared with.
Hold the result across renders.seriesProps rebuilds both props on every call, and a chart whose props change identity re-serializes its whole dataset — over the bridge, on native. Wrap it in useMemo keyed on the data, as above.
annotation
annotations is a union discriminated by type, so writing one by hand means remembering which fields belong to which variant: a range takes start/end, a point takes x/y, and a line takes one value plus the axis it sits on. These set the discriminant and let autocomplete offer only the fields that variant actually has.
A single marked point, placed by coordinate. Takes x and y.
annotation.text
ChartTextAnnotation
—
Free text on the plot. Omit x and y and the renderer places it.
Because they are functions returning objects, a project's own vocabulary is a one-line wrapper — and the place to put the decision once rather than at each call site.
tsx
const baseline = (value: number, label: string) => annotation.line({ axis: 'y', // Omit dash for a solid rule; width is in points on both platforms. dash: [1, 4], id: 'baseline', label, // A dark chip behind the digits, and the side that keeps them inside the plot. labelBackground: '#000000', labelPosition: 'auto', // Fade the rule out entirely while a price is being read. scrubOpacity: 0, value, width: 1.5, })const marketOpen = (category: string) => annotation.line({ axis: 'x', badge: '↑', dash: [2, 4], id: 'open', size: 18, value: category })<Chart.Line annotations={[baseline(range.baseline, 'Prev close'), marketOpen('09:30')]} … />
axis
One builder per axis position. start and end put the labels in a gutter on that side; overlay puts them inside the plot against its trailing edge and reserves no gutter, which keeps the full width for the marks.
labelInset — how far an overlaid label sits off that trailing edge — only means something when the labels are inside the plot, because a gutter axis has no edge to sit off. So it is overlay's alone, and the other two do not accept it.
tsx
const priceAxis = (domain: { max: number; min: number }) => axis.overlay({ domain, format: { decimals: 2 }, grid: false, labelInset: 22, labelSize: 13, ticks: false, // Only the two prices a reader is actually looking for. tickValues: [domain.min, domain.max], })<Chart.Line xAxis={axis.start({ grid: false })} yAxis={priceAxis(domain)} … />
Labels inside the plot, no gutter reserved. The only position that takes labelInset.
They describe the wider axis, and it is not native-only. What these return is a NativeChartAxisOptions, which is what xAxis and yAxis take on every entry point — so the overlaid position, labelInset, labelSize and ticks all work on a web chart too. Plain ChartAxisOptions objects stay valid everywhere, because the wider type is a superset of them.
reveal
The first-render entrance, passed as animation.reveal. Only a traced entrance has a frontier to light, so flashColor, trackColor and startOpacity are draw's alone — a fade has nothing to flash, and therefore takes only its duration and easing.
tsx
const arrival = animation({ reveal: reveal.draw({ duration: 420, // A brief brightening as the trace lands. flashColor: '#ffe3e8', flashDuration: 480, // Hold the glow a beat, then let it leave in one piece. flashEasing: 'ease-in-out', flashHold: 220, // Draw the whole path faintly first, so the shape reads from frame one. trackColor: '#8a8a8a', trackOpacity: 0.4, }), updates: false,})<Chart.Line animation={arrival} … />
Traces the marks along the x axis. Takes the flash, track and easing fields. Forms with no direction — radial and hierarchical ones — fall back to a fade.
How the mark under the finger is picked out, passed as interaction.marker. The two styles read different fields: span is how far a segment reaches along the line and means nothing to a dot, size is a dot's diameter and means nothing to a segment. The type permits both at once; these do not.
tsx
const scrubbing = interaction({ crosshair: 'x', crosshairStyle: { color: '#ffffff', width: 1 }, // Dim the rest of the line so the lit segment reads as the reading. dimOpacity: 0.38, haptics: true, hover: 'nearest', marker: marker.segment({ color: '#ffffff', glow: glow({ color: '#ff3b4a', opacity: 0.32, radius: 42 }), span: 2, }),})<Chart.Line interaction={{ ...scrubbing, tooltip: false }} … />
Brightens the stretch of line around the mark and blooms behind it, which reads as light moving along the data. Takes span; rejects size.
A marker is not a tooltip. It says only which mark is being read, never what it says — so use it when the value is shown outside the plot, and pair it with useChartScrub to drive that readout.
The passthroughs
The option groups with no variants to get wrong get a plain identity function each. They add no safety a prop's own type does not already give. What they add is a name to import and hold, so a preset can be declared and reused away from the chart that consumes it, and read as one thing rather than as an anonymous object literal.
tsx
// chart-style.ts — declared once, imported by both the line and the candlestick chart.export const chartTheme = theme({ colors: { label: '#8a8a8a' } })export const priceFormat = format({ decimals: 2, locale: 'en-US' })export const card = surface({ background: '#0b0b0b', cornerRadius: 16, padding: 12 })export const priceLine = seriesStyle({ glow: glow({ opacity: 0.16, radius: 7 }), strokeWidth: 2.3 })
A hard disc behind a point — unlike a glow it has an edge, so a small bright dot can sit in a larger ring.
There is no builder for a pulse. It is the one nested object that also takes a boolean: pulse: true is the whole of what most live points want, and annotation.point already types the object form for the ones that tune the rhythm.
Hook
useChartScrub
One of the two things a chart cannot answer from inside its own box: which datum is being read. It is for the pattern where a price, a date and a delta are real text above the plot, with the chart itself drawing no tooltip at all. The same hook on all three platforms — a finger on iOS and Android, a pointer on the web, reported as the same phases.
Hand its onInteraction straight to a chart and read selection for what is being read. The selection returns to null when the finger lifts or the pointer leaves the plot, which is the signal a readout needs to go back to its resting value.
A scrub is a continuous reading, so not every form has one. On the web it is Chart.Line and Chart.Candlestick that turn the pointer into one; the other forms report the mark the pointer landed on instead, which carries no index and so leaves the selection alone. On native a finger is read by its position along the category axis, so the forms that have one — the line, area, bar and candlestick families — are the forms a scrub means something on. The radial and flow ones have no ordered axis to walk, and nothing usable comes back from them.
What the finger is on, or null when nothing is being scrubbed.
onInteraction
(event) => void
—
Hand this straight to the chart’s onInteraction.
reset
() => void
—
Drops the selection, for a caller that has its own reason to.
geometry
ChartGeometry | null
—
Where the plot and its annotations sit in the chart view, for drawing your own views over them.
tsx
type ChartScrubSelection = { /** Position of the mark in the chart’s own data order. */ index: number category?: string /** Where the finger is, in the chart view’s own coordinate space. */ nativeX?: number nativeY?: number value?: number}type ChartGeometry = { annotations: readonly { id: string; x: number; y: number }[] plot: { height: number; width: number; x: number; y: number }}
Drawing your own overlay
For a view on an annotation, reach for annotationViews first. Key your own node by the annotation's id and the chart centres it on the spot, keeps its own mark for it out of the drawing, and moves it with the data — no geometry to hold, no positioning to write. A logo at the live reading, a letter on a rule, a price that stays with the last point. Every native form takes it, and on the web it is the five that report where an annotation landed — Chart.Line, Chart.Area, Chart.Bar, Chart.StackedBar and Chart.Candlestick.
geometry is the way down when a view is not on an annotation. It reports the plot's box and every annotation's x/y in the chart's own coordinates, so a card can follow the finger with selection.nativeX — which is the one thing annotationViews cannot place for you. An annotation that is only an anchor for such a view takes hidden: true: measured and reported as usual, drawn by nobody.
index is what makes it useful. A native interaction event carries the mark's position in your data, so the readout indexes your own arrays rather than reading numbers back off the chart. That is how a scrub can show a formatted price, a label and a percentage the chart never knew about.
The identity is stable.onInteraction and reset keep the same identity across renders, and the returned object only changes when the selection does — so a memo'd chart is not re-rendered by the readout updating above it.
Hook
useLastReading
A series is often shorter than its axis on purpose — a trading session still in progress, a forecast that has not started — so its last slot is not its last reading. This walks back to where the data really ends, which is where a "now" marker belongs. It returns null when the series has no readings at all. Unlike useChartScrub it reads nothing but the arrays you pass it, so it is on every entry point, web included.
The series values. Trailing null entries are skipped, as are gaps.
tsx
type ChartReading = { category: string index: number value: number}
The native renderers already agree with it. A scrub stops at the same point this returns, so a "now" marker placed here is exactly where the finger can no longer go — the chart and the marker cannot disagree about where the data ends.
Loading
Hold isLoading true while the data is in flight. The chart shows the shape it is about to be, at the height it will occupy, and cross-fades into the plot when the flag drops — same grid cell, same size, so nothing on the page moves when the marks land.
The placeholder is derived from the props the chart already has: one legend row per series, and axis rows only where an axis is visible. There is nothing to configure and nothing to keep in sync when the chart changes.
The first frame is a loading state too. A chart has to read its colors off the document before it can paint, so the built-in placeholder also covers that frame — with isLoading false and data in hand. The wrapper carries aria-busy while either is true, and the placeholder itself is aria-hidden.
Native draws one too, in its own way.isLoading means the same thing on iOS and Android, and both draw a shimmering placeholder shaped like the form they stand in for — a ring for the radial ones, a row of columns for the bar family, a curve for everything else. What they do not have is a skeleton slot or a .Skeleton component to mount on its own: those are DOM composition, and the placeholder there is one view the renderer draws.
Skeleton props
Every form also exposes its placeholder on its own, as Chart.Line.Skeleton — for when the chart is not mounted yet at all: a Suspense fallback, a route placeholder, a dashboard slot whose query has not started. These props apply only there; a chart driven by isLoading fills them in itself.
Reserved height. Match the chart it stands in for.
legendCount
number
0
Legend rows to reserve. Drawn from two up — a single series gets no legend, so reserving a row for one would leave a gap the chart never fills.
xAxis
boolean
true
Reserves the horizontal-axis label row.
yAxis
boolean
true
Reserves the vertical-axis label column.
className
string
—
CSS class applied to the skeleton root.
Custom skeleton
skeleton takes a rendered element, not a component, and replaces the built-in one while isLoading is true. Keep its height equal to the chart's so the swap still costs no layout shift.
It covers isLoading only. The frame before the first paint still uses the built-in placeholder, because that one is derived from the chart and always matches it. Legend rows and axis gutters are yours to mirror here — axis shapes the built-in placeholder, not this one.
Frame and legend
Chart.Frame is the card a chart can sit in: a title, a description, one row for filters, and a caption underneath for the source or the caveat. It is optional — a chart dropped straight into a dashboard grid needs no card — and when it is used it is the standard card recipe, so a chart never invents its own container.
The header only exists when at least one of title, description and actions is set, so a frame with none of them is a plain card around the plot.
Frame is not surface. The frame is a card with type in it, rendered around the chart; surface is the box the plot itself is painted on, and it exists on native too. Use the frame for a titled dashboard card, surface when the chart needs its own background or padding.
Frame props
Prop
Type
Default
Description
children*
ReactNode
—
Chart or composed visualization content.
title
string
—
Heading rendered above the chart.
description
string
—
Supporting text below the title.
actions
ReactNode
—
Filters and controls aligned with the heading.
caption
string
—
Source, method or caveat below the chart.
className
string
—
CSS class applied to the frame.
Legend props
A chart renders its own legend from two series up, and none for a single one, so Chart.Legend is for the surface that places identity itself — one legend above a row of small multiples, or a legend that doubles as a series filter. Colors come in already resolved, so pin slot or color on the series and both agree by construction.
The example app ships a full stock quote screen in the shape Revolut's has: a headline price that follows the finger, a smoothed intraday line that stops where the session does, a range selector, a candlestick toggle, and native tabs and headers above it all. It is the reference for what the native renderers are for — everything on the screen except the plot itself is ordinary React Native.
One screen, three renderers — the web build, Compose on Android, SwiftUI on iOS — in the appearance you are reading this page in.
What it uses
Prop
Type
Default
Description
Chart.Line
revolut-line-chart.tsx
—
The intraday price: smoothed, glowing, with a dashed baseline rule, an event badge, and a pulsing point at the last reading that useLastReading finds.
Chart.Candlestick
revolut-candlestick-chart.tsx
—
The same screen switched to OHLC, with the candle width and radius measured off the design and the volume histogram left off.
useChartScrub
use-quote-readout.ts
—
Turns the scrub into the price, the delta and the date above the plot, and back to the resting reading when the finger lifts. No tooltip is drawn by either chart.
geometry
quote-chart-overlay.tsx
—
The reading card and the event badge are React Native views placed over the plot from the geometry the layout phase reports — not something the library draws.
Platform files
revolut.ios.tsx · revolut.android.tsx
—
One screen per platform: SwiftUI through @expo/ui on iOS, Compose on Android, sharing the data, the theme and both charts.
The whole screen is in the repository, and apps/example runs it on a device with yarn ios:example or yarn android:example.
A study, not a product. The screen is built to test the library against a design people already know by heart. It uses generated data, and it is not affiliated with or endorsed by Revolut.
Versions are cut with changesets and published from CI with npm provenance, so the git tag, the GitHub release and the version on npm are always the same number. What changed in each one is also on this site.
More
Changelog
New updates and improvements to Zyplot. The notes are read straight out of the package's own CHANGELOG.md at build time, so this page, npm and the GitHub release cannot disagree about what shipped.
Give an annotation label a chip and a side that keeps it readable. labelBackground paints a rounded fill behind the label, so a rule's value stays legible where the marks run through it, and labelPosition: 'auto' picks the side from where the rule sits: above it in the lower half of the plot, below it higher up. Fixed sides still win when named, so nothing changes for annotations that already pass 'top' or 'bottom'.
Let a rule annotation set its own thickness. width joins dash and color on ChartLineAnnotation, honoured on iOS, Android and the web — the line width was pinned at 1 in the native drawing code, so a reference line could be dashed and coloured but never made heavier or lighter than the hairline it started as.
Android drew both the width and the dash lengths in pixels while they are given in dp, which on a 3× screen made a dashed rule a third of its asked-for thickness with a third of its asked-for dash; both are scaled now.
Put your own component where an annotation lands, with annotationViews.
The chart already reported where every annotation ended up, and an app that wanted a logo at the live reading or its own head on a rule had to take it from there: hold the geometry in state, absolutely position a view over the plot, keep the two in step. That work was the same every time, so it lives in the chart now. Key a node by the annotation's id and it is centred on the spot, moves with the data, and the mark the chart would have drawn there is left out — one prop instead of an overlay of your own.
The annotations you leave out keep the dot, glow and pulse the renderers draw, on all three platforms, and nothing about the built-in marks has changed. An annotation can also be an anchor and nothing else: hidden: true keeps it measured and reported in geometry while drawing none of it, which is what a view of your own placed by hand — a card following the finger, say — wants underneath it.
Charts that draw annotations but had no pointer layer to measure them (Chart.Area, Chart.Bar, Chart.StackedBar on the web) now report geometry on the 'layout' phase like the rest, so the views land there too.
Report where the plot and its annotations ended up, so an app can draw its own overlays instead of the ones the chart bakes in. Native charts now emit a 'layout' phase carrying geometry — the plot's box and every annotation's position, in the chart view's own coordinate space — and useChartScrub returns it as geometry alongside the selection, which also carries the pointer's nativeX/nativeY now.
That is enough to place any React component over the chart: your own badge on an event annotation instead of the built-in glyph-in-a-circle, your own card at the reading under the finger, a logo, a button, whatever the design asks for. Leave badge off the annotation and the chart draws only the rule, leaving the head to you.
Let every entry point hand over the whole contract, not most of it.
@hzblj/zyplot/ios and @hzblj/zyplot/android re-exported the shared types and Chart, and then stopped: the builders and useLastReading are values, and export type * does not carry a value. So the import the documentation shows for a platform file — import {annotation, Chart} entries now export the fifteen builders and useLastReading alongside useChartScrub, so a *.ios.tsx file needs one import rather than two.
@hzblj/zyplot/web re-exported a hand-kept subset of the shared types, and several a web chart actually needs were missing from it: ChartCandlestickDatum and ChartCandlestickStyle, which Chart.Candlestick takes; ChartRangeAnnotation and ChartTextAnnotation, two of the four members of the union annotations is; StyledChartSeries, what the series builder returns; and the small unions the documented shapes are written in terms of — ChartSymbol, ChartAxisScale, ChartCoordinate, ChartSurfacePadding and the rest. Typing a candle array or a helper that returns a range annotation meant importing from @hzblj/zyplot-core directly. They are all re-exported now.
Chart.TimeSeries was also the one web form whose list prop stayed mutable: its series is readonly Omit<ChartSeries, 'values'>[] now, like every other list the web charts take.
Two knobs for reading a candlestick chart. interaction.highlightBlend says how far the read mark is lifted towards interaction.highlightColor, so at 0.5 a red candle lights up red instead of turning white — a flat replacement threw the series colour away, which is the one thing a candle's colour is for. style.candleRadius rounds the candle body, and rounds the wick's caps with it so the wick does not read as a cut-off stub against a rounded body.
Let the mark under the pointer light up. interaction.highlightColor draws the read mark in its own colour, so a scrub reads as one candle lit rather than as every other candle merely dimmed — dimming alone leaves the read one in its resting colour, which is hard to pick out against a plot that has only lost a little contrast. Implemented for candlesticks on both platforms, alongside the dimOpacity fix that made the rest fade at all.
Draw with the theme's font on iOS and Android, and read the last three theme colours everywhere.
theme.typography.fontFamily reached only the web renderer before: the native ones decoded colors and dropped typography on the floor, so a chart beside a <Text> in the app's own font drew its axis labels in the system one. Both now resolve the family the way their platform resolves text — iOS through the registered-font lookup behind UIFont(name:), Android through React Native's ReactFontManager, which covers assets/fonts, res/font and anything expo-font registered at runtime. A family the app never shipped falls back to the platform font, exactly as a canvas does on the web. It reaches every string either renderer draws: axis labels and titles, tick labels, annotation labels and badges, rule labels, the tooltip and the gauge reading.
Three colours were also being decoded and then ignored:
- axis now colours the tick marks on both platforms. Android drew no ticks at all until now, so its ticks axis option had nothing to switch off; it draws them beside every label the x and y axes place, an overlaid y axis excepted — it reserves no gutter for one to sit in. - surface now fills the tooltip card. It replaces the hardcoded near-black on Android and the system material on iOS, which is still what a chart with no surface in its theme gets. - background now paints the plot on Android when no plot.backgroundColor overrides it, the order iOS already resolved in.
Give each theme shape a name of its own. @hzblj/zyplot/web exported two incompatible types called ChartTheme — the wide palette Chart.Provider takes, and the narrower one a chart's own theme prop takes — and the explicit export won, so a value annotated ChartTheme was not assignable to the prop of the same name.
There are now three, and the two wider ones are supersets of the portable one, so a single object can be passed to any of the three props:
- ChartTheme is the portable subset: axis, categorical, grid, label, negative, positive, surface, track, and typography. Every key on it is one all four renderers draw with. Its colours are ChartThemeColors, exported for building a theme up in parts. - NativeChartTheme adds colors.background, the chart's own fill, which only a native surface paints. background has moved here off ChartTheme: the web renderer never drew it, and the box a web chart sits on is surface.background. - ChartProviderTheme adds border, diverging, muted and sequential — the palettes and greys that only a CSS variable can carry — and is what the web Chart.Provider takes.
Chart.Provider also reads the flat negative and positive now, as the shorthand for diverging.negative and diverging.positive. Passing the five-key diverging object still wins over them, so setting both is not ambiguous.
Give the pulse on a live point a rhythm, and hand it over. pulse on a point annotation now takes a ChartPulse — color, duration, interval, opacity, scale — as well as the true it took before, and true now means one bloom of 450 ms followed by a rest of 1550 ms, at 2.2× the point's resting ring.
That replaces a single 1.8 s expansion that faded to nothing with no rest between cycles: the ring spent almost the whole cycle nearly transparent, which read as no animation at all. The ring's colour is settable too, and falls back to the glow's colour and then to the point's own — on iOS a pulse with no glow used to inherit the glow's .clear and draw nothing at all.
Android had no pulse to speak of — the parameter was threaded through the drawing code but nothing ever animated it — and now draws the same bloom off the same clock.
Make the data shapes one contract across web and native. The web entry point had its own copies of ChartSeries, ChartDatum, ChartTimePoints and the other per-form inputs, identical to the ones in @hzblj/zyplot-core except that their arrays were mutable. It now re-exports the core types, so a value typed once can be handed to a web chart and a native one.
Every web chart prop that takes a list — series, categories, data, nodes, cells, groups, rows, values — now accepts a readonly array, as the native props and web's own Chart.Candlestick already did. Passing an as const array or the result of a readonly-returning selector no longer needs a cast.
Let the entrance name its own curve. reveal.draw and reveal.fade take an easing — 'ease-in' | 'ease-in-out' | 'ease-out' | 'linear' — and reveal.draw also takes a flashEasing for the glow's decay. Both were hard-coded before: a trace always ran at a steady speed, a fade always eased out, and the flash always shed most of its bloom in the first frames after landing, which reads as the glow leaving while the trace is still arriving. flashEasing: 'ease-in-out' keeps the bloom up a moment longer so it leaves in one piece.
A spring is deliberately absent from ChartRevealEasing: an entrance that overshoots would trace past the last data point and come back.
Defaults are unchanged — 'linear' for a trace, 'ease-out' for a fade and for the flash — so existing charts animate exactly as before.
Make the web renderer read a pointer the way the native ones read a finger, so a screen built on a scrub is one screen on all three platforms rather than two.
useChartScrub is now exported from the web entry point as well. The scrub lifetime is no longer native's alone: ChartInteractionEvent carries phase, index and geometry on every platform, and Chart.Line and Chart.Candlestick report them from the pointer — 'began' when it enters the plot, 'changed' as it moves, 'ended' when it leaves, and 'layout' with the plot's box and every annotation's position once the chart has measured itself. NativeChartInteractionEvent stays as a name for the same shape.
The web props also take the fuller presentation vocabulary they were previously typed out of, and the renderer honours it:
- interaction.marker lights the mark being read — a stretch of the line for marker.segment, a bloom behind the candle for a mark that has its own body — with crosshairStyle, dimOpacity, highlightColor and highlightBlend around it. - animation.reveal traces a line open: trackColor lays the shape down first, and flashColor with its glow, hold and decay lands with the frontier and then leaves. - annotations draw their glow, halo, pulse, badge, label, labelBackground, labelPosition and scrubOpacity. - axis.overlay puts the tick labels inside the plot at labelInset, tickValues pins them to exact readings, and plotDimensionStartPadding/plotDimensionEndPadding keep the marks clear of them. - seriesStyles[id].glow blooms behind a stroke, and style.candleWidth/style.wickWidth size a candle. style.candleRadius is the one prop the web cannot honour: ECharts draws a candle as a single path with no corner radius to give. - Every chart takes a theme of its own, merged over Chart.Provider's, so a preset that carries colours can be handed to a web chart and a native one alike.
Give Android candlesticks the entrance they already had on iOS. A traced reveal pins the growth factor at 1 — the trace is meant to come from the reveal's own fraction — but the candlestick drawing never received it, so reveal.draw drew every candle at once while iOS brought them in left to right. Candles now land one slot at a time off the same fraction, with the slot width keyed to the full count so nothing re-spaces as they arrive.
Draw Android charts at the size they were asked for. Every absolute number a chart takes — plot padding, axis gutters, stroke and wick widths, annotation dot and halo sizes, glow radii, badge and label geometry, marker sizes — is authored in dp, but the Compose canvas measures in pixels and the drawing code used the two interchangeably. On a 3× phone that made all of it a third of its intended size: a 6 dp live dot drew at 2 dp, a 42 dp glow barely left the stroke, and the axis gutter was too narrow to keep labels off the trace. Pointer hit-testing had the same mismatch, since the plot box was measured in dp and compared against a pixel pointer.
The geometry an app lays its own views out with is now reported in dp, matching iOS's points, so an overlay positioned from useChartScrub's geometry and nativeX lands where the chart drew rather than a screen away.
Let an annotation badge cap its rule instead of sitting on it. The badge was placed a default annotation gap below the plot edge, so a stub of the rule stuck out above it and the dashes ran straight through the circle — the glyph read as floating in the plot rather than as the rule's head. It now sits flush with the plot edge, and the rule starts below it: on Android the rule is drawn from under the badge, and on iOS the badge paints the chart's plot (or theme) background behind itself to mask the part it covers. Charts with a transparent plot background keep the previous translucent badge, since there is nothing to mask with.
Honour interaction.dimOpacity on a candlestick chart. It faded the marks on every other form but did nothing here, so reading a candle left the rest of the series at full strength and the read one hard to pick out. Candles either side of the selection now fade back the same way series marks do.
Stop painting canvas text in the browser's serif when the page declares no font.
A web chart reads the font in effect where it sits and hands it to the canvas, which is what makes it match the type around it. When nothing up the tree declares a font, though, that read answers with the user agent's default — a serif — and the chart drew its axis labels in Times beside text that was not.
React Native Web is where this shows: its reset puts no font-family on html or body and gives each <Text> the system stack through a class of its own, so an Expo web app has nothing for a chart to inherit however deeply it looks. Every chart in one rendered its numbers in Times.
The inherited font is now compared against what the browser resolves with no author styles in play, and falls back to system-ui and the platform stack behind it when the two match. A page that does set a font is untouched: inheritance still wins, and --zyplot-font-family and theme.typography.fontFamily still override both.
Things the renderers drew that they were never asked to.
The web entry point imported a stylesheet that only gathers two others with @import, and not every bundler follows those in development — Metro leaves them out, so a chart came up with no styles at all. That loses the layer its placeholder and its plot share: the two stack up instead, and everything an app positions over the plot is measured from a canvas that starts a placeholder's height too low. It now imports the built stylesheets themselves.
Chart.Line put a symbol on every reading. Its own documentation said symbols appear on hover, and the native renderers draw none — a dot per datum is a mark the reader did not ask for. Set seriesStyles[id].symbol and they come back.
A range or text annotation drew nothing at all, because the components that render them were never registered with ECharts. They are now. A reference line's label showed the value it sits on rather than the label given to it, which dropped a trailing zero from a price; and with an 'overlay' axis it printed that number a second time, on top of the axis' own — labelPosition: 'auto' now keeps it at the rule's leading end, away from them.
Scrubbing a candlestick chart left every candle the pointer had passed still lit, because ECharts' highlight adds to a set rather than replacing it. The bloom behind the read mark was a flat fill with a shadow around it, which reads as a box sitting behind the candle however soft its edges are; it is a radial gradient now, which has no edge to read.
A traced entrance ran behind the placeholder, so the plot cross-faded in with the trace already part-drawn — or already finished, depending on which won the race. The marks now wait for the placeholder to go. Its flash was also rebuilt at full strength whenever the data changed, and nothing was left to put it out: a chart that had already made its entrance kept the glow for good.
On Android an overlaid axis reserved a gutter for its labels _and_ kept plotDimensionEndPadding clear of them, so the marks stopped a label's width further from the edge than on iOS. An overlaid axis reserves no gutter — that is what overlaying means.
Add repository metadata and a bundled LICENSE file to both published packages. npm verifies a provenance-signed publish against repository.url, so the missing field left @hzblj/zyplot-core unpublishable.
Cross-platform React charting behind one package and one shared TypeScript contract: ECharts and uPlot on web, Swift Charts on iOS, and Jetpack Compose on Android, with both native renderers reached through a single Expo Module named Zyplot.