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, widened with the Swift Charts axis options.
@hzblj/zyplot/android
Android
—
Shared forms plus Chart.Lollipop and Chart.Waterfall, widened with the Compose axis options.
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 and no engine, so it is also the only form that needs no client boundary.
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.
tsx
forecast.ios.tsx imports @hzblj/zyplot/ios
forecast.android.tsx imports @hzblj/zyplot/android
forecast.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. The props are the same ones documented under Charts.
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.
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
—
Native has no custom skeleton slot; isLoading draws the platform spinner.
colorMode
shared
"system"
Per chart on native: there is no cascade to inherit through, so Chart.Provider scopes surface and theme but not the color mode.
interaction
shared
—
Delivered through onInteraction on both platforms, with haptics available natively.
labels
shared
—
Boxplot terminology is honoured natively too — the tooltip shows the five-number summary in your wording.
Native
iOS
@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 provides that neither the web nor the Compose renderer has a counterpart for. They are absent from the shared @hzblj/zyplot namespace, so TypeScript rejects them unless the file imports @hzblj/zyplot/ios.
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.
The Swift renderer
All of it is readable — the module is a few hundred lines of SwiftUI over Swift Charts, and every documented form links to the exact file that draws it from its own page.
Prop
Type
Default
Description
ios/Bridge/ZyplotModule.swift
bridge
—
The Expo module: the ExpoView, its single prop, and the SwiftUI hosting controller.
ios/Core/ZyplotModels.swift
model
—
Codable models the JSON configuration decodes into. Adding a prop starts here.
ios/Charts/Marks/ZyplotMarksChart.swift
marks
—
Every Swift Charts mark — line, bar, pie, scatter, boxplot, rule, range.
The forms Swift Charts has no mark for, drawn on a SwiftUI Canvas.
iOS axis options
xAxis and yAxis accept everything ChartAxisOptions defines, plus these Swift Charts scrolling controls.
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.
yAxis.plotDimensionStartPadding
number
—
Extra padding before the first mark.
yAxis.plotDimensionEndPadding
number
—
Extra padding after the last mark.
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 Swift Charts has no equivalent for. They are absent from the shared @hzblj/zyplot namespace, so TypeScript rejects them unless the file imports @hzblj/zyplot/android.
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.
The Compose renderer
One Canvas draws the whole chart — marks, axis text, grid, annotations and the tooltip — so a chart is a single view rather than a tree of them. Every documented form links to the draw function behind it from its own page.
Prop
Type
Default
Description
android/.../bridge/ZyplotModule.kt
bridge
—
The Expo module: the ExpoComposeView, its single prop, and the composable root.
android/.../core/ChartConfiguration.kt
model
—
Parses the JSON configuration and resolves the palette, axes and theme.
android/.../charts/ZyplotChart.kt
host
—
Animation, gestures, the surface container and dispatch to the draw functions.
android/.../charts/{cartesian,radial,specialized}
canvas
—
The draw functions themselves, split into cartesian, radial and specialized.
Android axis options
xAxis and yAxis accept everything ChartAxisOptions defines, plus overflow handling for long tick labels.
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.
This is the web shape. The native renderers take a ChartTheme of their own — no sequential, diverging, muted or border, and a background instead — and they accept it per chart as well as from the provider, because a native chart has no cascade to read a variable out of.
tsx
type ChartTheme = {
colors?: {
/** Slots 1…7, in order. A series takes one by index or by its own slot. */
categorical?: string[]
/** Low → high, five steps. Heatmap, treemap, sunburst. */
sequential?: string[]
/** Signed scales: diverging bars, and any positive/negative encoding. */
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
/** 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.
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.
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 types
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.
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.
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
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
/** Pins the extent. Omit either end to keep it computed. */
domain?: { min?: number; max?: number }
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)[]
}
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?: 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
/** 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 native view's coordinate space. */
nativeX?: number
nativeY?: number
}
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.
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
delay?: number
/** "linear" | "ease-in" | "ease-out" | "ease-in-out" | "spring" */
easing?: ChartAnimationEasing
}
Loading states
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.
Shape-matched placeholders are a web feature.isLoading means the same thing on iOS and Android, but there it draws the platform's own spinner: no .Skeleton component and no skeleton slot, because there is no DOM to build one out of.
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.