PVR Tech Studio

Charts

Four charting libraries — Recharts, ApexCharts, ECharts, and Chart.js — each demoed on its own page, all pulling colors from the token-aware useChartTokens() hook.

7 min read
Updated July 15, 2026

Luminaux intentionally demos four chart libraries rather than committing to one, so a buyer can adopt whichever they already know or prefer. Every chart pulls its colors from useChartTokens(), a hook that reads the app's raw --* design tokens off <html> and re-reads on theme / skin / OLED change, so all charts re-skin live in light and dark. Chart pages are React.lazy-loaded so they split out of the main bundle.

LibraryPackagesRouteStrengths
Rechartsrecharts/charts/rechartsReact-first, declarative (charts are JSX). Great default for most dashboards.
ApexChartsapexcharts + react-apexcharts/charts/apexchartsConfig-object API, rich built-in interactivity, easy gradients/donuts/heatmaps.
EChartsecharts + echarts-for-react/charts/echartsThe most powerful/flexible for large or unusual visualizations.
Chart.jschart.js + react-chartjs-2/charts/chartjsCanvas-based, small and fast. Also powers cards in the widgets library.

All four demo the same core dataset (a 12-month revenue series, a last-year comparison, a 4-slice traffic breakdown) as six panels each: an area/line revenue chart, a grouped bar chart, a two-series line comparison, a donut/pie, plus two library-specific extras (Recharts: radar + radial bar; ApexCharts: radial bar + heatmap; ECharts: radar + gauge; Chart.js: radar + polar area). Month labels are derived from the active language via monthsShort(i18n.language) — never hardcoded.

Architecture & files

PathResponsibility
src/hooks/useChartTokens.tsReads raw --* tokens off <html>; exposes semantic colors + a skin-aware series[]; re-reads on theme/skin/OLED change. Shared by all chart pages & widgets.
src/pages/charts/RechartsPage.tsxRecharts demo (area / bar / line compare / donut / radar / radial bar).
src/pages/charts/ApexChartsPage.tsxApexCharts demo (area / bar / line / donut / radialBar / heatmap).
src/pages/charts/EChartsPage.tsxECharts demo (line-area / bar / line compare / pie / radar / gauge).
src/pages/charts/ChartjsPage.tsxChart.js demo (line-area / bar / line compare / doughnut / radar / polar area) — includes the ChartJS.register(…) setup.
src/lib/dates.tsmonthsShort(lang) — locale-derived month labels (dates never get i18n keys).
src/routes.tsxAll four chart routes are React.lazy-loaded.

Each page renders a <PageHeader> and wraps every chart in a Panel (with fill + mandatory title/subtitle) inside a Stagger/StaggerItem entrance, on a responsive grid-cols-1 lg:grid-cols-2 grid.

useChartTokens()

The single most important pattern: never hardcode chart colors — read them from useChartTokens() so the chart follows the theme, dark mode, and every color skin.

export interface ChartTokens {
    primary: string
    success: string
    warning: string
    danger: string
    info: string
    foreground: string
    mutedForeground: string
    border: string
    surface: string
    surfaceMuted: string
    background: string
    /** Categorical series palette derived from the tokens above. */
    series: string[]
}

Key behaviors:

  • Reads raw --* vars, not the Tailwind --color-* aliases (which aren't emitted as real CSS variables). This matches the project's SCSS/token rule.
  • series is skin-aware (built by buildSeries): if the active skin defines a categorical --c1..--c6 palette (as Prism does) those values are used verbatim; otherwise it derives a distinct multi-hue set anchored on the skin's primary hue (the first entry is the exact --primary, the rest are hue-rotations with normalized saturation/lightness, lightened in dark mode); if --primary isn't a parseable hex it falls back to [primary, success, warning, info, danger].
  • Re-reads on change. A MutationObserver on <html> watches the class (dark mode), data-skin, and data-oled attributes, so charts recolor live — no reload.
  • SSR-safe fallback. When document is undefined the initial state is a static EMPTY palette.

Feed axis/grid strokes from tk.mutedForeground / tk.border, series fills from tk.primary (single-series) or tk.series (categorical), tooltip surfaces from tk.surface / tk.foreground. For two-series comparisons the demo pages pair tk.primary + tk.info — the two tokens every skin overrides — rather than dipping into success/warning/danger.

Per-library APIs & examples

Charts are JSX components (AreaChart, BarChart, LineChart, PieChart, RadarChart, RadialBarChart, plus Area/Bar/Line/Pie/Cell/Radar/RadialBar, axes, grids, Tooltip, Legend) wrapped in ResponsiveContainer. Colors are passed as props: stroke={tk.primary}, fill, contentStyle for the tooltip.

export function RevenueArea({lang}: {lang: string}) {
    const tk = useChartTokens()
    const revenue = monthsShort(lang).map((m, i) => ({m, v: values[i]}))
    const tooltip = {background: tk.surface, border: `1px solid ${tk.border}`, borderRadius: 10, color: tk.foreground}
    return (
        <ResponsiveContainer width="100%" height={280}>
            <AreaChart data={revenue}>
                <defs>
                    <linearGradient id="rcArea" x1="0" y1="0" x2="0" y2="1">
                        <stop offset="0%" stopColor={tk.primary} stopOpacity={0.35} />
                        <stop offset="100%" stopColor={tk.primary} stopOpacity={0} />
                    </linearGradient>
                </defs>
                <CartesianGrid strokeDasharray="3 3" stroke={tk.border} vertical={false} />
                <XAxis dataKey="m" stroke={tk.mutedForeground} fontSize={12} tickLine={false} axisLine={false} />
                <YAxis stroke={tk.mutedForeground} fontSize={12} tickLine={false} axisLine={false} />
                <Tooltip contentStyle={tooltip} cursor={{stroke: tk.border}} />
                <Area type="monotone" dataKey="v" stroke={tk.primary} strokeWidth={2} fill="url(#rcArea)" />
            </AreaChart>
        </ResponsiveContainer>
    )
}

For a categorical pie, cycle the derived palette: <Cell fill={tk.series[i % tk.series.length]} />. Recharts needs a ResponsiveContainer with an explicit height and a width-bearing parent.

Configuration & customization

The rule across all four libraries is identical: source every color from tk. A few patterns worth calling out:

  • Single-series accenttk.primary; second seriestk.info.
  • Categorical (pie/donut/multi-series)tk.series, cycled with i % tk.series.length.
  • Axes / grid linestk.mutedForeground (labels) and tk.border (grid strokes).
  • Tooltip surfacetk.surface background + tk.foreground text + tk.border outline. (The Chart.js page inverts this for a high-contrast tooltip; both are token-pure.)
  • Transparent chart background so the Panel surface shows through — Apex uses chart.background: 'transparent', ECharts uses backgroundColor: 'transparent'; Recharts and Chart.js are transparent by default.

Lazy-loading for bundle splitting

Charting libraries are heavy, so every chart page is React.lazy-loaded in src/routes.tsx. Each library then lands in its own Vite chunk that's only fetched when the user visits that page. While a lazy page loads it shows the shared PageLoader Suspense fallback (gated by config.routeLoader).

Best practices

  • Always read colors from useChartTokens() — never hardcode hex. Non-negotiable for Chart.js: a canvas cannot resolve CSS variables.
  • Use tk.series for categorical data, cycling with the modulo index. Pair tk.primary + tk.info for two-series compares.
  • Keep chart backgrounds transparent so the Panel surface shows through in both light and dark.
  • Set fontFamily: 'inherit' (Apex) so charts use the app's skin-aware font.
  • Register Chart.js pieces at module scope — one ChartJS.register(…) per module, listing exactly what you use.
  • Derive date labels from the locale (monthsShort in src/lib/dates.ts) — dates never get i18n keys.
  • Lazy-load chart pages — these libs are heavy; keep them out of the main bundle.
  • Make charts responsive — Recharts via ResponsiveContainer; Apex/ECharts fill their container and take a height; Chart.js needs maintainAspectRatio: false inside a fixed-height wrapper.

Troubleshooting

SymptomCause & fix
Chart colors don't change with dark mode / skinColors were hardcoded. Route every color through useChartTokens().
Colors look wrong / transparent when read in plain CSSYou read a --color-* alias. Use the raw tokens (--primary, --surface, …).
Chart.js throws "category" is not a registered scaleMissing registration — add the scale/element/controller to ChartJS.register(…).
Chart.js chart stretches / wrong heightSet responsive: true + maintainAspectRatio: false and give it a fixed-height wrapper div.
ApexCharts tooltip/text stays light in dark modePass theme: {mode} and tooltip: {theme} derived from the .dark class.
Chart shows a solid box behind itChart background isn't transparent — set background/backgroundColor: 'transparent'.
Series colors all look the same on a custom skinThe derived palette anchors on --primary. Accept the hue-rotated set, or give the skin its own --c1..--c6 (Prism pattern).
Recharts chart has zero sizeWrap it in ResponsiveContainer with an explicit height and a width-bearing parent.

FAQ

Why ship four chart libraries? So a buyer can choose the one they prefer. Each is demoed with the same six-panel layout and dataset for easy comparison.

Which should I use? Recharts by default (declarative React). ApexCharts for rich built-in interactivity. ECharts for the most powerful/complex visualizations. Chart.js for a small, fast canvas renderer — and it's what the widgets library already uses.

How is the categorical palette built? buildSeries in useChartTokens.ts: skin-provided --c1..--c6 if present (Prism), otherwise the exact primary plus five hue-rotations of it, falling back to [primary, success, warning, info, danger] when the primary isn't a parseable hex.

Where else is Chart.js used? The widgets library — DonutWidget, GaugeWidget, and the MiniBarChart primitive are react-chartjs-2 charts, colored from the same useChartTokens() hook.

Was this page helpful?