PVR Tech Studio

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.

11 min read
Updated August 13, 2026

Overview

The template intentionally demos four chart libraries rather than committing to one, so a buyer can adopt whichever they already know or prefer:

LibraryPackagesDemo page / routeStrengths
RechartsrechartsRechartsPage — /charts/rechartsReact-first, declarative (charts are JSX components). Easiest to compose and read. Great default for most dashboards.
ApexChartsapexcharts + react-apexcharts (Chart)ApexChartsPage — /charts/apexchartsConfig-object API, rich built-in interactivity, polished defaults, easy gradients/donuts/heatmaps.
EChartsecharts + echarts-for-react (ReactECharts)EChartsPage — /charts/echartsThe most powerful/flexible for large or unusual visualizations (gauges, big option surface).
Chart.jschart.js + react-chartjs-2 (Line/Bar/…)ChartjsPage — /charts/chartjsCanvas-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

PathResponsibility
src/hooks/useChartTokens.tsReads 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.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).
The route entriesAll 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.
  • series is skin-aware (built by buildSeries in the hook):
    1. If the active skin defines a categorical palette — --c1..--c6, as the Prism skin does in src/styles/_skin-prism.scss — those values are used verbatim.
    2. 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 (offsets 55° / 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.
    3. If --primary isn't a parseable hex color it falls back to the static [primary, success, warning, info, danger] list.
  • Re-reads on change. A MutationObserver on <html> watches the class (dark mode), data-skin, and data-oled attributes 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 document is undefined the initial state is a static EMPTY palette, 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: false in 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 with i % tk.series.length (or sliced, e.g. tk.series.slice(0, 4) for a 4-slice donut).
  • Axes / grid lines → tk.mutedForeground (labels) and tk.border (grid strokes).
  • Tooltip surface → tk.surface background + tk.foreground text + tk.border outline. (The Chart.js page inverts this — tk.foreground background with tk.background text — 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.

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.series for categorical data (pie slices, multi-series), cycling with the modulo index so it never runs off the end. Pair tk.primary + tk.info for two-series compares — those are the tokens every skin overrides.
  • Keep chart backgrounds transparent ('transparent') so the Panel surface 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 (monthsShort and friends in src/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 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() — the hook re-reads on theme/skin/OLED change.
Colors look wrong / transparent when read in plain CSSYou read a --color-* alias. Use the raw tokens (--primary, --surface, …); useChartTokens already does.
Chart.js throws "category" is not a registered scaleMissing registration — add the scale/element/controller to the ChartJS.register(…) call (e.g. PolarAreaController for PolarArea).
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 (see config above).
Chart shows a solid box behind itChart background isn't transparent — set background/backgroundColor: 'transparent'.
Initial bundle is largeA chart page is being imported eagerly — make sure it is code-split, per the existing pattern.
Categorical palette repeats oddly / crashes on indexIndex tk.series with i % tk.series.length.
Series colors all look the same on a custom skinThe 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 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. 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 buildSeries derivation) 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..--c6 palette, 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 demo namespace + the Intl date 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.

Was this page helpful?