Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 15

Data visualization

This chapter wraps a charting library so that charts read design tokens, follow the active theme, and stay accessible.

Monthly signups 300 200 100 0 Jan Feb Mar May 290 Apr
RudiBarChart: Recharts underneath, with series and chart colors read from the same tokens.

Charts are where a token system usually gives up. A charting library draws with SVG and canvas, takes colors as JavaScript strings, and knows nothing about CSS custom properties. So a chart gets a hardcoded palette, and the moment someone switches to a dark theme the chart is still drawing 2563eb on a near-black background.

This chapter solves that. Most of the work is in one hook that reads CSS custom properties into the JavaScript values a chart library needs, and re-reads them when the theme changes.

Choosing a charting library

npm install recharts

Recharts is composable React components over D3, which suits this library: <BarChart> contains <XAxis>, <CartesianGrid>, and <Bar> as real children, so wrapping it means composing components rather than building a configuration object.

The alternatives are reasonable. Visx gives lower-level primitives and more control at the cost of writing your own axes and tooltips. Chart.js is canvas-based, which performs better past a few thousand points but leaves nothing in the DOM for assistive technology or your stylesheet. Plotting from scratch with D3 is a project, not a chapter.

Recharts is a substantial dependency, pulling in a set of D3 modules behind it. Chapter 12’s external predicate keeps it out of your bundle, and consumers who never import a chart never ship it to their users: npm still installs it, but their bundler leaves it out. If you would rather not put it in dependencies at all, publishing charts as a separate package is a defensible split.

The theme bridge

A chart needs "#2563eb". Your token system has --rudi-color-dataviz-series-1. Nothing resolves one to the other automatically, because custom properties are resolved by the browser during styling and never exposed to JavaScript on their own.

getComputedStyle is the bridge:

const styles = getComputedStyle(document.documentElement)
const value = styles.getPropertyValue('--rudi-color-dataviz-series-1').trim()

That reads the value as it currently resolves, which means it already accounts for whichever data-theme is active. Read it once at mount and you have theme-correct colors. But read it only once and the chart keeps those colors after the user switches themes, because nothing told React to re-render.

useChartTheme handles both halves:

import { useCallback, useEffect, useState } from 'react'

const TOKEN_MAP = {
  axisColor: '--rudi-component-chart-axis-color',
  gridColor: '--rudi-component-chart-grid-color',
  labelColor: '--rudi-component-chart-label-color',
  legendColor: '--rudi-component-chart-legend-color',
  tooltipBg: '--rudi-component-chart-tooltip-bg',
  tooltipText: '--rudi-component-chart-tooltip-text',
  tooltipBorder: '--rudi-component-chart-tooltip-border',
  chartBg: '--rudi-component-chart-bg',
} as const

const SERIES_TOKENS = Array.from(
  { length: 8 },
  (_, i) => `--rudi-color-dataviz-series-${i + 1}`,
)

const FALLBACK_COLORS = [
  '#2563eb', '#d946ef', '#f59e0b', '#10b981',
  '#ef4444', '#8b5cf6', '#06b6d4', '#f97316',
]

function resolveTokens(): ChartTheme {
  if (typeof window === 'undefined') {
    return { colors: FALLBACK_COLORS, axisColor: '#6b7280' /* …etc */ }
  }

  const styles = getComputedStyle(document.documentElement)
  const resolve = (token: string) => styles.getPropertyValue(token).trim()

  const colors = SERIES_TOKENS.map(
    (token, i) => resolve(token) || FALLBACK_COLORS[i],
  )

  const theme: Record<string, string> = {}
  for (const [key, token] of Object.entries(TOKEN_MAP)) {
    theme[key] = resolve(token) || ''
  }

  return { colors, ...theme } as ChartTheme
}

export function useChartTheme(): ChartTheme {
  const [theme, setTheme] = useState<ChartTheme>(resolveTokens)

  const updateTheme = useCallback(() => {
    setTheme(resolveTokens())
  }, [])

  useEffect(() => {
    updateTheme()

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (
          mutation.type === 'attributes' &&
          mutation.attributeName === 'data-theme'
        ) {
          updateTheme()
        }
      }
    })

    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-theme'],
    })

    return () => observer.disconnect()
  }, [updateTheme])

  return theme
}

Four decisions in that hook are worth understanding, because each one is a bug you would otherwise ship.

The MutationObserver watches data-theme on the root element. This is the piece that makes charts theme-aware rather than theme-initialized. Chapter 13’s theme switching is pure CSS, so React has no idea it happened; every other component re-styles through the cascade without re-rendering. Charts are the one place that needs an explicit signal, and attributeFilter: ['data-theme'] keeps the observer from firing on unrelated attribute changes.

typeof window === 'undefined' guards server rendering. getComputedStyle does not exist on the server, and a library that throws during SSR is unusable in Next.js and Remix. On the server the hook returns the fallback palette, which renders a reasonable chart.

useEffect calls updateTheme() immediately, even though useState already initialized from resolveTokens. That looks redundant, and for the most part it is a safety net: it covers stylesheets that had not finished applying at first render. It does not fix hydration. React does not carry state from server to client, so on the client resolveTokens runs again with window defined, and the first client render already differs from the server’s fallback markup. If you server-render charts, initialize state with the fallback palette on both sides and resolve the tokens only inside the effect, so the first client render matches the HTML.

The series colors fall back and the chrome tokens do not. getPropertyValue returns an empty string for a property that does not exist, and an empty string is not a valid color, so a missing token produces an invisible element rather than an error.

Series colors are protected by || FALLBACK_COLORS[i]. The chrome tokens are not: the loop writes resolve(token) || '', so a missing --rudi-component-chart-axis-color yields an empty string and the axis disappears. That is worth fixing in your own version by giving TOKEN_MAP fallbacks the same way, and worth understanding either way, because an invisible axis reads as a styling bug rather than a missing token.

The tokens behind it

The eight series colors come from chapter 3’s dataviz ramp, aliased through semantic tokens:

"dataviz": {
  "series-1": { "$value": "{color.dataviz.1}" },
  "series-2": { "$value": "{color.dataviz.2}" }
}

Chart chrome, the axes, grid, labels, legend, and tooltip, lives in the component tier as component.chart.*. Both re-emit per theme, which is why chapter 13’s dark filter keeps dataviz.series-* while excluding the dataviz primitives beneath them. A blue that reads well on white is often too dark on near-black, so the series tokens are among the semantic colors the dark theme redefines, here as literal hex values rather than aliases.

Eight is a practical ceiling, not an arbitrary one. Past roughly eight categories, distinguishing series by color stops working for everyone, not only for colorblind readers. If a chart needs a ninth color, it usually needs a different chart.

The chart components

All three follow one shape. BarChart:

export interface RudiBarChartProps {
  /**
   * Row data. Values may be `null` to represent gaps in a series.
   */
  data: Array<Record<string, string | number | null>>
  dataKeys: string[]
  indexKey: string
  layout?: 'vertical' | 'horizontal'
  stacked?: boolean
  showLegend?: boolean
  showGrid?: boolean
  height?: number
  label: string
  className?: string
}

LineChart swaps the layout props for curved and showDots. DonutChart takes data as { name, value } pairs plus innerRadius and showLabels.

The implementation composes Recharts inside a labelled container:

export function RudiBarChart({
  data,
  dataKeys,
  indexKey,
  layout = 'vertical',
  stacked = false,
  showLegend = true,
  showGrid = true,
  height = 300,
  label,
  className,
}: RudiBarChartProps) {
  const theme = useChartTheme()
  const isHorizontal = layout === 'horizontal'

  return (
    <div className={cn('rudi-chart', className)} role="img" aria-label={label}>
      <ResponsiveContainer width="100%" height={height}>
        <RechartsBarChart
          data={data}
          layout={isHorizontal ? 'vertical' : 'horizontal'}
        >
          {showGrid && <CartesianGrid stroke={theme.gridColor} />}
          {/* Axis pair swaps type/dataKey depending on orientation;
              both pass tick={{ fill: theme.axisColor }} */}
          <XAxis dataKey={indexKey} tick={{ fill: theme.axisColor }} />
          <YAxis tick={{ fill: theme.axisColor }} />
          {dataKeys.map((key, i) => (
            <Bar
              key={key}
              dataKey={key}
              fill={theme.colors[i % theme.colors.length]}
              stackId={stacked ? 'stack' : undefined}
            />
          ))}
        </RechartsBarChart>
      </ResponsiveContainer>
    </div>
  )
}

Three things there generalize beyond charts.

label is required and becomes aria-label on a role="img" wrapper. A chart is a picture built from hundreds of SVG nodes. Left alone, a screen reader either announces nothing useful or reads every tick mark. Collapsing it to a single labeled image is the honest representation, which makes the label the entire accessible content: "Chart" is useless, "Monthly revenue by region, January through June" is not. Making it required forces the decision. One catch with Recharts 3: its accessibilityLayer prop defaults to true and makes the chart SVG focusable with arrow-key navigation, which puts an interactive element inside an image. Pass accessibilityLayer={false} to the chart so the wrapper’s label is the only thing assistive technology meets.

Series colors cycle with i % theme.colors.length. Nine series reuse the first color rather than crashing on undefined, which is a degraded chart instead of a broken one.

layout is inverted on the way through. The layout prop means the direction bars grow, which is how people describe bar charts. Recharts’ layout prop means the axis orientation, which is the opposite. Rather than exposing that inversion, the wrapper absorbs it. This is most of what wrapping a third-party library is for: a stable, sensible API that does not change when the dependency does.

data values are typed to allow null, which Recharts renders as a gap. Coercing missing data to zero draws a line to the floor and implies a measurement that was never taken.

What charts do not solve

Two limitations worth documenting rather than discovering.

A role="img" chart is described, not explored. A sighted user reads individual values off the axes; a screen reader user gets your one-sentence summary. For charts where the numbers matter, pair the chart with a data table, visually hidden if you like. Nothing in this component does that for you.

And color is the only channel distinguishing series in a default Recharts chart. The eight-color ramp is chosen for distinguishability, but for a stacked bar or a multi-line chart, patterns, direct labels, or varied dash styles carry information that color alone does not.

BarChart in Storybook — open in Storybook

What you have now

Three chart components that follow the theme, drawn from the same token pipeline as everything else, and a hook that is reusable for any other case where JavaScript needs a token value.

useChartTheme is the piece worth remembering. Any component that must hand a token value to a library that does not speak CSS, a canvas renderer, a map, a syntax highlighter, needs exactly this pattern: resolve through getComputedStyle, observe data-theme, fall back sensibly, and guard the server.

The next chapter covers the other component with a real dependency: a drag-and-drop board that stays operable from the keyboard.

This chapter has been corrected since it was first published: see what changed. Found an error? Report it.