Charts

The five-colour series ramp every chart draws from, how a series picks its colour, what to change to put a whole app's charts on brand, and how to build a chart the library does not ship.

Every chart, one ramp. Nothing in the library names a colour for a series — a LineChart does not know it is blue, it knows it is series one, and what series one resolves to is decided by the active theme. That is what makes a theme switch restyle every chart in an app rather than only the ones somebody remembered to update.

Which means putting every chart in your product on brand is five lines of CSS, and it means a chart with a hex in it is a chart that has quietly opted out of all of this.

The ramp

Five tokens, --color-chart-1 through --color-chart-5, ordered by prominence. chart-1 is the series the chart is about, and every theme starts it on that family's own accent; each further series takes the next.

They are a separate ramp from the status colours on purpose, and the difference is worth holding onto: a status colour means something — this failed, this is fine — while a series colour only has to be told apart from the other four. Reaching for destructive to draw a series called "Churn" borrows a meaning the reader will apply to every red thing on the screen.

TokenPanel lightPanel dark
--color-chart-1#262626#fafafa
--color-chart-2#3b82f6#60a5fa
--color-chart-3#10b981#34d399
--color-chart-4#f59e0b#fbbf24
--color-chart-5#8b5cf6#a78bfa

Every theme defines its own five. See Theming for the full set.

Which part takes which

A series-drawing part picks its token with colorIndex, and defaults to the next one along:

<LineChart data={traffic} xDataKey="month">
  <LineChart.Line dataKey="organic" />                 {/* chart-1 */}
  <LineChart.Line dataKey="paid" colorIndex={2} />     {/* chart-2 */}
  <LineChart.Line dataKey="referral" colorIndex={3} /> {/* chart-3 */}
</LineChart>
ChartThe part that takes colorIndex
LineChartLine, Area
AreaChartArea
BarChartBar
ScatterChartPoints
RadarChartSeries
RingChartRing
PlotLine, Area, Bars, Dots

PieChart and HexChart take their colours from the data instead, walking the ramp in order — their parts are one series each, so there is no per-part slot to put an index in.

Two charts are not a series ramp at all, and both are right to be:

  • HeatmapChart derives a five-step ramp from a single base colour — one measure at five intensities is not five series.
  • CandlestickChart uses --color-success and --color-destructive. Up and down are not two series, they are two states of one, and that convention is older than any palette.

Putting every chart on brand

Redefine the five in your own global.css, in the same @variant shape the library uses, and every chart in the app follows — including the ones you have not written yet.

global.css
@import 'panelui-native/theme.css';

@layer theme {
  :root {
    @variant light {
      --color-chart-1: #c2410c;
      --color-chart-2: #ea580c;
      --color-chart-3: #f59e0b;
      --color-chart-4: #16a34a;
      --color-chart-5: #2563eb;
    }
    @variant dark {
      --color-chart-1: #fb923c;
      --color-chart-2: #fdba74;
      --color-chart-3: #fcd34d;
      --color-chart-4: #4ade80;
      --color-chart-5: #60a5fa;
    }
  }
}

The @variant shape is not optional — see Changing a colour for why the web's :root / .dark pattern does not work here. If your app ships the other four themes, the same block takes moon, moon-dark, grass and grass-dark alongside these two.

Set light and dark. A ramp defined only in light leaves every dark theme on the library's defaults, which is the kind of thing nobody notices until a screenshot goes out.

Two things worth getting right while you are in there. Keep them in order of prominence — the ordering is a promise every chart relies on to decide what the eye lands on first. And check them against the dark background as well as the light one: a ramp picked on white routinely has two colours that collapse into each other at dark-mode brightness.

One series, one colour

Where a series has to be a particular colour — a brand, a category the reader already associates with a colour, a comparison against something drawn elsewhere in the same colour — name it directly. color beats colorIndex wherever both exist.

<LineChart data={traffic} xDataKey="month">
  <LineChart.Line dataKey="ours" color="#2563eb" />
  <LineChart.Line dataKey="theirs" colorIndex={5} />
</LineChart>

On the charts whose colours come from the data, it goes on the datum:

const attribution: HexDatum[] = [
  { label: 'Renewals', value: 3420, color: '#2563eb' },
  { label: 'New', value: 1880 },       // chart-2
  { label: 'Expansion', value: 840 },  // chart-3
];

color takes a literal colour, not a token name — the one exception is HeatmapChart, whose color and emptyColor accept either, so color="--color-chart-3" recolours the ramp and keeps following the theme. Everywhere else, a token name is a string the renderer cannot resolve. To follow the theme, change the token rather than naming it.

The furniture

Grids, axes and tooltips are not series and do not draw from the ramp. They default to --color-border for rules and the muted foreground for labels, and each takes a color of its own — so a chart's furniture can be dialled back without touching its data.

<LineChart data={traffic} xDataKey="month">
  <LineChart.Grid color="rgba(0,0,0,0.04)" rows={3} />
  <LineChart.Line dataKey="organic" />
  <LineChart.XAxis ticks={4} />
  <LineChart.Tooltip color="#94a3b8" />
</LineChart>

Most also take an opacity, which is usually the better knob: it keeps the rule following the theme instead of pinning it to a colour that was chosen against one background.

Weight, fill and shape

Colour is not the only thing that separates one series from another, and on a small chart it is often not the most effective. These are per-part, and they are the same idea — say less, or say it more quietly:

PropWhereWhat it does
strokeWidthLine, RadarChart.Series, RingChartHow heavy the stroke is
fillOpacityAreaChart.Area, RadarChart.SeriesHow solid the fill under it is
dimOpacityPieChart.Slices, HexChart.CellsHow far the others fade when one is selected
cornerRadiusPieChart.Slices, CandlestickChart.Candles, HeatmapChart.CellsHow round each mark is
emptyColorHexChart.Cells, HeatmapChart.CellsThe cells with nothing in them
shapeHexChartWhether the cells cluster or fill in reading order

A chart of four overlaid series reads better as one heavy line and three light ones than as four of equal weight in four colours. The ramp says which series; the weight says which one matters.

When the shape is what you need to change

Everything above changes how a chart looks. None of it changes what a chart is — a BarChart draws bars, and there is no prop that puts a line over them or a shaded band behind them.

Plot is for that. It is the same drawing machinery with nothing decided: it measures a box, resolves one scale, and draws whatever marks you put in it, in the order you write them. Columns with a line over them, a series against a reference band, a mark that exists nowhere in this library — all of them are children.

<Plot data={months} xDataKey="month" aspectRatio={1.7}>
  <Plot.Header title="Revenue" value={money(total)} />
  <Plot.Legend labels={{ revenue: 'Revenue', costs: 'Costs' }} />
  <Plot.Grid />
  <Plot.Bars dataKey="revenue" colorIndex={2} />
  <Plot.Line dataKey="costs" colorIndex={1} curve="linear" />
  <Plot.Dots dataKey="costs" colorIndex={1} />
  <Plot.YAxis />
  <Plot.XAxis ticks={6} />
</Plot>

The marks take color and colorIndex exactly as the built-in charts do, so a composed chart follows the same ramp and the same theme switch as everything beside it.

A mark of your own

Where the built-in marks run out, Plot.Layer puts your children into the SVG tree and usePlot() hands them the resolved geometry — the plot box, the tweening y-domain, the reveal. The scale functions ship with it and every one is a worklet, so a mark you write is rebuilt on the UI thread on the same frames the built-in marks are, rather than laid over them a frame late.

import Animated, { useAnimatedProps } from 'react-native-reanimated';
import { Path } from 'react-native-svg';
import { Plot, usePlot, yOf } from 'panelui-native';

const AnimatedPath = Animated.createAnimatedComponent(Path);

/** A shaded band between two values — nothing in the library draws this. */
function BandBetween({ low, high }: { low: number; high: number }) {
  const { plot, domainMin, domainMax } = usePlot();
  const tint = useCSSVariable('--color-chart-3');

  const animatedProps = useAnimatedProps(() => {
    const min = domainMin.value;
    const max = domainMax.value;
    if (max === min) return { d: '' };
    const top = yOf(high, plot, min, max);
    const bottom = yOf(low, plot, min, max);
    return { d: `M${plot.left},${top}H${plot.left + plot.width}V${bottom}H${plot.left}Z` };
  });

  return (
    <AnimatedPath
      animatedProps={animatedProps}
      fill={typeof tint === 'string' ? tint : '#34d399'}
      fillOpacity={0.14}
    />
  );
}

<Plot data={months} xDataKey="month">
  <Plot.Grid />
  {/* Written before the series, so it is drawn behind them. */}
  <Plot.Layer>
    <BandBetween low={20000} high={28000} />
  </Plot.Layer>
  <Plot.Area dataKey="revenue" />
  <Plot.Line dataKey="revenue" />
</Plot>

Anything that is text or takes a touch goes in Plot.Overlay instead, which is a React Native view laid over the drawing. SVG text ignores the platform's text scaling and the theme's font, and a gesture handler cannot be attached to an SVG node at all.

Both marks on a plot share one axis, so both have to be in the same unit. Revenue and costs are money and the gap between them is the margin; revenue and order counts are not, and hundreds under tens of thousands is a line lying flat along the floor of the plot. There is no secondary axis — draw the second quantity as its own chart.

Reach for a named chart first. LineChart, BarChart and the rest carry decisions Plot leaves to you — where the baseline belongs, what a gap in a series means, how a loading state should read — and a chart assembled by hand has to make every one of them again. Plot is marked alpha for the same reason its parts are settled and its geometry names are not.

Resolving a colour yourself

Anything drawing outside the component tree — an SVG of your own beside a chart, a native control, a gradient — needs the resolved string rather than a class. useCSSVariable gives it, and re-resolves on a theme change.

import { useCSSVariable } from 'uniwind';

const series = useCSSVariable('--color-chart-1');

// Undefined until the stylesheet has been read, so anything painting on the
// first frame needs a fallback rather than a crash.
<Svg>
  <Path fill={typeof series === 'string' ? series : '#3b82f6'} />
</Svg>

That guard is the idiom throughout the library — it is how every chart resolves its own grid and series colours. See Theming for the rest of it.

The rule

Never a raw hex in a chart you want to keep. It is the same rule Colors opens with, and charts are where it is easiest to break and hardest to spot: a series pinned to #1e293b looks deliberate in light mode and is invisible in dark, and nothing about the call site says so. Name the token, change the token.

On this page