Charts
Four charting libraries — Recharts, ApexCharts (react-apexcharts), ECharts (echarts-for-react), and Chart.js (react-chartjs-2) — are each demoed on their own page, so buyers can pick whichever fits their needs.
Overview
The template intentionally demos four chart libraries rather than committing to one, so a buyer can adopt whichever they already know or prefer:
| Library | Packages | Demo page / route | Strengths |
|---|---|---|---|
| Recharts | recharts | RechartsPage — /charts/recharts | React-first, declarative (charts are JSX components). Easiest to compose and read. Great default for most dashboards. |
| ApexCharts | apexcharts + react-apexcharts (Chart) | ApexChartsPage — /charts/apexcharts | Config-object API, rich built-in interactivity, polished defaults, easy gradients/donuts/heatmaps. |
| ECharts | echarts + echarts-for-react (ReactECharts) | EChartsPage — /charts/echarts | The most powerful/flexible for large or unusual visualizations (gauges, big option surface). |
| Chart.js | chart.js + react-chartjs-2 (Line/Bar/…) | ChartjsPage — /charts/chartjs | Canvas-based, small and fast, huge ecosystem. Also powers several cards in the widgets library. |
All four demo the same core dataset (a 12-month revenue series, a last-year comparison, and 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 (department goals).
- ApexCharts — radial bar + heatmap (weekly activity).
- ECharts — radar + gauge (score).
- Chart.js — radar + polar area.
Month labels are not hardcoded — every page derives them from the active language via
monthsShort(i18n.language) (src/lib/dates.ts), and all panel titles / series names come
from the demo i18n namespace.
When to use which
- Recharts — the default. Reach for it first: declarative JSX, composes cleanly, smallest mental overhead for React devs.
- ApexCharts — when you want rich out-of-the-box interactivity/polish (smooth gradients, donut labels, heatmaps) from a single options object.
- ECharts — when you need heavy-duty or unusual charts (large datasets, complex axes, specialized series like gauges) and want maximum control.
- Chart.js — when you want a small, fast canvas renderer with a huge plugin ecosystem, or to stay consistent with the widgets library (which already uses it).
You do not have to keep all four in a production app — pick one and delete the other demo pages/deps if you want a leaner bundle.
Architecture & files
| Path | Responsibility |
|---|---|
src/hooks/useChartTokens.ts | Reads raw --* tokens off <html>; exposes semantic colors + a skin-aware categorical series[] (see below); re-reads on theme/skin/OLED change. Shared by all chart pages & widgets. |
src/pages/charts/RechartsPage.tsx | Recharts demo (area / bar / line compare / donut / radar / radial bar). |
src/pages/charts/ApexChartsPage.tsx | ApexCharts demo (area / bar / line / donut / radialBar / heatmap). |
src/pages/charts/EChartsPage.tsx | ECharts demo (line-area / bar / line compare / pie / radar / gauge). |
src/pages/charts/ChartjsPage.tsx | Chart.js demo (line-area / bar / line compare / doughnut / radar / polar area) — includes the ChartJS.register(…) setup. |
src/lib/dates.ts | monthsShort(lang) — locale-derived month labels (dates never get i18n keys). |
| The route entries | All four chart routes are code-split, one chunk per charting library. |
src/components/widgets/ | Chart.js is also used outside the demo pages — DonutWidget, GaugeWidget, and primitives/MiniBarChart render react-chartjs-2 charts (see Widgets). |
Each page renders a <PageHeader> and wraps every chart in a Panel (with fill +
mandatory title/subtitle, per the house Panel convention) inside a Stagger/StaggerItem
entrance from @/components/motion, on a responsive grid-cols-1 lg:grid-cols-2 grid.
Usage
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.
import {useChartTokens} from '@/hooks/useChartTokens'
function MyChart() {
const tk = useChartTokens()
// tk.primary, tk.success, tk.warning, tk.danger, tk.info,
// tk.foreground, tk.mutedForeground, tk.border,
// tk.surface, tk.surfaceMuted, tk.background,
// tk.series -> string[] skin-aware categorical palette
}Feed axis/grid strokes from tk.mutedForeground / tk.border, series fills from tk.primary
(single-series) or tk.series (categorical), and 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 (which are not
skin-specific).
API / Props
useChartTokens(): ChartTokens
Located at src/hooks/useChartTokens.ts. Returns a ChartTokens object read from the raw CSS
custom properties on document.documentElement:
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 — e.g.--primary,--surface,--border(the--color-*aliases aren't emitted as real CSS variables). This matches the project's SCSS/token rule. seriesis skin-aware (built bybuildSeriesin the hook):- If the active skin defines a categorical palette —
--c1..--c6, as the Prism skin does insrc/styles/_skin-prism.scss— 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, and the rest are hue-rotations of it (offsets55° / 120° / 190° / 255° / 310°) with saturation/lightness normalized (S clamped to 0.5–0.72, L to 0.45–0.6, lightened +0.08 in dark mode) so slices stay vivid and distinct on any skin — including the mono Graphite skin — in both themes. - If
--primaryisn't a parseable hex color it falls back to the static[primary, success, warning, info, danger]list.
- If the active skin defines a categorical palette —
- Re-reads on change. A
MutationObserveron<html>watches theclass(dark mode),data-skin, anddata-oledattributes and re-reads the tokens, so charts recolor live when the user toggles theme, switches skin, or enables OLED — no reload. - SSR-safe fallback. When
documentis undefined the initial state is a staticEMPTYpalette, so the hook can be called during server render without throwing.
Because the returned object identity changes on token change, deriving memoized chart configs
on [tk] (or just reading tk.* inline in render) is enough to keep charts in sync.
Recharts
Charts are JSX components (AreaChart, BarChart, LineChart, PieChart, RadarChart,
RadialBarChart, plus Area/Bar/Line/Pie/Cell/Radar/RadialBar, XAxis, YAxis,
PolarGrid, PolarAngleAxis, CartesianGrid, Tooltip, Legend) wrapped in
ResponsiveContainer. Colors are passed as props: stroke={tk.primary}, fill,
contentStyle for the tooltip, etc.
ApexCharts
react-apexcharts default export (Chart) takes options: ApexOptions, series,
type ('area' | 'bar' | 'line' | 'donut' | 'radialBar' | 'heatmap' | …), and height.
Colors go through options.colors (array) plus grid/axis/tooltip theme fields.
ECharts
echarts-for-react (ReactECharts) takes option: EChartsOption, notMerge, and style.
Colors go through the option tree: series[].itemStyle.color, color: tk.series,
axisLabel.color, splitLine.lineStyle.color, etc.
Chart.js
react-chartjs-2 exposes one typed component per chart type — the demo page uses Line,
Bar, Doughnut, Radar, and PolarArea — each taking data (labels + datasets) and
options: ChartOptions<'line' | …>. Two Chart.js-specific requirements:
-
Registration is explicit and tree-shakable. Every controller/element/scale/plugin you use must be registered once at module scope, or the chart throws at runtime:
import { ArcElement, BarElement, CategoryScale, Chart as ChartJS, Filler, Legend, LineElement, LinearScale, PointElement, PolarAreaController, RadialLinearScale, Tooltip, } from 'chart.js' ChartJS.register( CategoryScale, LinearScale, RadialLinearScale, PointElement, LineElement, BarElement, ArcElement, PolarAreaController, Filler, Tooltip, Legend, ) -
Sizing — set
responsive: true+maintainAspectRatio: falsein options and give the chart a fixed-height wrapper (<div style={{height: 280}}>), the same "bounded container" idea as AG Grid.
Because Chart.js paints to a canvas, it can't read CSS variables at all — every color must
be a resolved string from useChartTokens(). For soft translucent fills the page appends an
alpha suffix to the token hex: const soft = (hex: string) => `${hex}33` (~20% alpha
#rrggbbaa).
Configuration & customization
Theme-aware colors, everywhere
The rule across all four libraries is identical: source every color from tk. A few
patterns worth calling out:
- Single-series accent →
tk.primary; second series →tk.info. - Categorical (pie/donut/multi-series) →
tk.series, cycled withi % tk.series.length(or sliced, e.g.tk.series.slice(0, 4)for a 4-slice donut). - Axes / grid lines →
tk.mutedForeground(labels) andtk.border(grid strokes). - Tooltip surface →
tk.surfacebackground +tk.foregroundtext +tk.borderoutline. (The Chart.js page inverts this —tk.foregroundbackground withtk.backgroundtext — for a high-contrast tooltip; both are token-pure.) - Transparent chart background so the
Panelsurface shows through — Apex useschart.background: 'transparent', ECharts usesbackgroundColor: 'transparent'; Recharts and Chart.js are transparent by default.
ApexCharts dark mode
ApexCharts has its own light/dark theme switch. The template derives it from the .dark class
and feeds it in so Apex's internal theming matches the app:
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const base: ApexOptions = {
chart: {toolbar: {show: false}, foreColor: tk.mutedForeground, background: 'transparent', fontFamily: 'inherit'},
theme: {mode: isDark ? 'dark' : 'light'},
grid: {borderColor: tk.border, strokeDashArray: 4},
dataLabels: {enabled: false},
tooltip: {theme: isDark ? 'dark' : 'light'},
legend: {labels: {colors: tk.mutedForeground}},
}fontFamily: 'inherit' keeps Apex on the app's skin-aware font.
Lazy-loading for bundle splitting
Charting libraries are heavy, so every chart page is code-split. Each library lands in its own chunk that is only fetched when the user visits that page — the initial app bundle stays small:
All four chart pages are additionally marked client-only, so they never render on the server. Each
one gets a small ClientPage.tsx shim beside its page.tsx:
// ClientPage.tsx — the boundary has to be its own module
'use client'
import {clientOnly} from '@/platform/clientOnly'
const RechartsPage = clientOnly(() => import('@/pages/charts/RechartsPage').then((m) => ({default: m.RechartsPage})))
export default function ClientPage() {
return <RechartsPage />
}Why a separate module: dynamic(…, {ssr: false}) is rejected inside a Server Component, so the
boundary cannot live in page.tsx itself. ApexCharts and ECharts require this — they touch window
when their module loads. Recharts and Chart.js are included too, because a chart measures its container
before it can draw, which makes a server-rendered first frame worthless rather than merely different.
Examples
Recharts — token-driven area chart
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'
import {useChartTokens} from '@/hooks/useChartTokens'
import {monthsShort} from '@/lib/dates'
const values = [40, 65, 50, 80, 60, 95, 70, 88, 62, 78, 96, 84]
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:
{traffic.map((_, i) => (
<Cell key={i} fill={tk.series[i % tk.series.length]} />
))}ApexCharts — token-driven donut options
import Chart from 'react-apexcharts'
import type {ApexOptions} from 'apexcharts'
import {useChartTokens} from '@/hooks/useChartTokens'
function TrafficDonut() {
const tk = useChartTokens()
const donut: ApexOptions = {
chart: {background: 'transparent', foreColor: tk.mutedForeground, fontFamily: 'inherit', toolbar: {show: false}},
colors: tk.series,
labels: ['Direct', 'Organic', 'Referral', 'Social'],
legend: {position: 'bottom', labels: {colors: tk.mutedForeground}},
stroke: {width: 0},
plotOptions: {pie: {donut: {size: '68%'}}},
}
return <Chart options={donut} series={[62, 21, 11, 6]} type="donut" height={280} />
}ECharts — token-driven bar option
import ReactECharts from 'echarts-for-react'
import type {EChartsOption} from 'echarts'
import {useChartTokens} from '@/hooks/useChartTokens'
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
const revenue = [40, 65, 50, 80, 60, 95]
function OrdersBar() {
const tk = useChartTokens()
const bar: EChartsOption = {
backgroundColor: 'transparent',
grid: {left: 44, right: 16, top: 20, bottom: 36},
tooltip: {trigger: 'axis'},
xAxis: {type: 'category', data: months, axisLine: {lineStyle: {color: tk.border}}, axisLabel: {color: tk.mutedForeground}},
yAxis: {type: 'value', splitLine: {lineStyle: {color: tk.border}}, axisLabel: {color: tk.mutedForeground}},
series: [{type: 'bar', data: revenue, itemStyle: {color: tk.primary, borderRadius: [6, 6, 0, 0]}}],
}
return <ReactECharts option={bar} notMerge style={{height: 280}} />
}Chart.js — token-driven filled line
import {CategoryScale, Chart as ChartJS, Filler, LinearScale, LineElement, PointElement, Tooltip} from 'chart.js'
import {Line} from 'react-chartjs-2'
import type {ChartOptions} from 'chart.js'
import {useChartTokens} from '@/hooks/useChartTokens'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip)
const soft = (hex: string) => `${hex}33` // ~20% alpha for canvas #rrggbbaa fills
function RevenueLine({labels, values}: {labels: string[]; values: number[]}) {
const tk = useChartTokens()
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {display: false}},
scales: {
x: {grid: {display: false}, ticks: {color: tk.mutedForeground}, border: {color: tk.border}},
y: {grid: {color: tk.border}, ticks: {color: tk.mutedForeground}, border: {display: false}},
},
}
return (
<div style={{height: 280}}>
<Line
options={options}
data={{
labels,
datasets: [{
data: values,
borderColor: tk.primary,
backgroundColor: soft(tk.primary),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
}],
}}
/>
</div>
)
}Best practices
- Always read colors from
useChartTokens()— never hardcode hex. It's what makes charts follow dark mode and every skin automatically. This is non-negotiable for Chart.js: a canvas cannot resolve CSS variables. - Use
tk.seriesfor categorical data (pie slices, multi-series), cycling with the modulo index so it never runs off the end. Pairtk.primary+tk.infofor two-series compares — those are the tokens every skin overrides. - Keep chart backgrounds transparent (
'transparent') so thePanelsurface shows through cleanly 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 (it's what keeps the chunk tree-shaken). - Derive date labels from the locale (
monthsShortand friends insrc/lib/dates.ts) — dates never get i18n keys. - Keep chart pages code-split — these libraries are heavy; keep them out of the main bundle.
- Pick one library for a real app if bundle size matters — the four-way demo is a showcase, not a requirement to ship all four. If you keep only one, Chart.js is already a dependency of the widgets library.
- Make charts responsive — Recharts via
ResponsiveContainer; Apex/ECharts fill their container width and take aheight; Chart.js needsmaintainAspectRatio: falseinside a fixed-height wrapper.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| Chart colors don't change with dark mode / skin | Colors were hardcoded. Route every color through useChartTokens() — the hook re-reads on theme/skin/OLED change. |
| Colors look wrong / transparent when read in plain CSS | You read a --color-* alias. Use the raw tokens (--primary, --surface, …); useChartTokens already does. |
Chart.js throws "category" is not a registered scale | Missing registration — add the scale/element/controller to the ChartJS.register(…) call (e.g. PolarAreaController for PolarArea). |
| Chart.js chart stretches / wrong height | Set responsive: true + maintainAspectRatio: false and give it a fixed-height wrapper div. |
| ApexCharts tooltip/text stays light in dark mode | Pass theme: {mode} and tooltip: {theme} derived from the .dark class (see config above). |
| Chart shows a solid box behind it | Chart background isn't transparent — set background/backgroundColor: 'transparent'. |
| Initial bundle is large | A chart page is being imported eagerly — make sure it is code-split, per the existing pattern. |
| Categorical palette repeats oddly / crashes on index | Index tk.series with i % tk.series.length. |
| Series colors all look the same on a custom skin | The derived palette anchors on --primary. Either accept the hue-rotated set, or give the skin its own --c1..--c6 (Prism pattern). |
| Recharts chart has zero size | Wrap 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. You can delete the pages/deps you don't use.
Which should I use? Recharts by default (declarative React). ApexCharts for rich built-in interactivity from an options object. 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 do charts stay in sync with the theme and skins?
useChartTokens() reads the raw --* CSS vars and a MutationObserver re-reads them whenever
<html>'s class, data-skin, or data-oled changes, so charts recolor live.
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 with normalized saturation/lightness (slightly
lightened in dark mode), falling back to [primary, success, warning, info, danger] when the
primary isn't a parseable hex.
Can I add more colors to the categorical palette?
Either define --c1..--c6 in your skin's token block (they win outright), or extend the
offsets array in buildSeries — still token-anchored, never literal hex in components.
Why are chart pages lazy-loaded? Charting libraries are large. Lazy-loading puts each in its own chunk fetched only when visited, keeping the initial load fast.
Where else is Chart.js used?
The widgets library (src/components/widgets/) — DonutWidget, GaugeWidget, and the
MiniBarChart primitive are react-chartjs-2 charts, colored from the same useChartTokens()
hook. See Widgets.
Notes for designers & content editors
- Chart colors are derived from the design tokens, not chosen per-chart. To change a chart's
palette, adjust the tokens (or the
buildSeriesderivation) centrally — every chart follows. - Charts automatically adapt to light/dark, every color skin, and OLED mode. There's
no separate "dark version" of a chart to maintain. On skins without their own
--c1..--c6palette, multi-slice charts get hue-rotations of the skin's primary — intentionally, so the chart always "belongs" to the skin. - The demo data (monthly revenue, traffic sources) is placeholder — swap it for real data when
wiring to the API. Month labels and panel titles are localized (the
demonamespace + theIntldate helpers), so translated builds need no chart edits. - Categorical palettes cycle in a fixed order; that ordering is intentional for consistency across the four libraries.
Related
Tables & data grid
shares useChartTokens and the lazy-load pattern (AG Grid theme is built from the same tokens)
Widgets
the dashboard widget library; its donut/gauge/mini-bar cards are Chart.js via the same token hook
Design tokens & dark mode
the -- tokens the charts read
Design skins
how skins swap the tokens charts pick up (and Prism's --c1..--c6 categorical palette)
Animation & effects
the Stagger/StaggerItem entrance used on chart pages
i18n
the demo namespace and the "dates never get i18n keys" rule
Hooks reference
useChartTokens details
Architecture & routing
route registration, code splitting, and the PageLoader Suspense fallback
Was this page helpful?
