chartTheme
Code
ts
import chartTheme from '@/components/data/chartTheme';src/components/data/chartTheme.ts
AI prompt
text
Build a shared chart theming module in React + TypeScript: one place for chart colours and number formats, so every chart in a dashboard uses the same palette and adapts to light and dark mode. Charts use it with Recharts. It is a module of hooks, pure functions and formatters, with no UI of its own.
## Dark mode
Both hooks read the active scheme from your theme state: the `.dark` class on <html>, or a theme context that toggles it. Each returns a plain object of hex strings to pass as Recharts props.
## `chartPalette(dark: boolean)` and the `useChartPalette()` hook
Returns `{ dark, categorical, ordinal, sequential, diverging, muted, surface, grid, axis, ink, inkSecondary }`:
- `categorical`, eight hues in a FIXED order. Light: #2a78d6, #eb6834, #1baf7a, #eda100, #e87ba4, #008300, #4a3aa7, #e34948. Dark: #3987e5, #d95926, #199e70, #c98500, #d55181, #008300, #9085e9, #e66767.
- Series take slots in the order they are DECLARED, never by rank, so filtering one out does not repaint the rest.
- The order is also the colour-blind-safety mechanism: each adjacent pair stays distinguishable under colour-vision-deficiency simulation.
- Three light hues fall under 3:1 contrast on white, so charts carry a legend and a table view, and identity never rests on colour alone.
- A scatter, where every pair of series can touch, uses only the first three slots.
- Past eight series, fold the rest into "Other". Never generate a ninth colour.
- `ordinal` (a funnel's stage ramp, one hue). Light: #104281, #1c5cab, #2a78d6, #5598e7, #86b6ef. Dark: #cde2fb, #9ec5f4, #6da7ec, #3987e5, #256abf.
- `sequential` (heatmap, near zero → most). Light: #e8f1fd, #cde2fb, #9ec5f4, #6da7ec, #3987e5, #256abf, #184f95, #0d366b. Dark: #26324a, #184f95, #1c5cab, #256abf, #3987e5, #6da7ec, #9ec5f4, #cde2fb.
- `diverging`: negative #e34948 / dark #e66767, positive #2a78d6 / dark #3987e5, mid #e2e8f0 / dark #475569 (a grey midpoint that reads as "nothing").
- Chrome, light / dark:
- `muted` #94a3b8 / #64748b: the de-emphasis grey for context series and sparkline history.
- `surface` #ffffff / #1e293b: the card colour, used for 2px gaps between touching marks.
- `grid` #e2e8f0 / #334155.
- `axis` #64748b / #94a3b8.
- `ink` #0f172a / #f1f5f9 and `inkSecondary` #475569 / #cbd5e1.
- Export `type ChartPalette = ReturnType<typeof chartPalette>`.
## `useChartTheme()`, the older named-series theme
Returns `{ dark, axis, grid, tooltip, series }`:
- `axis` #6b7280 / dark #9ca3af, and `grid` #e5e7eb / dark #374151.
- `tooltip`: a CSSProperties object for Recharts `contentStyle`. Background #ffffff / #1f2937, border `1px solid` in the grid colour, radius 0.5rem, text #111827 / #f3f4f6, fontSize 12px.
- `series`: spend #f59e0b, deposit #10b981, registrations #6366f1, ftd #3b82f6, positive #10b981, negative #ef4444, retention #8b5cf6, retentionD30 #ec4899.
## Formatters (en-US)
- `compactCurrency`: USD, compact notation, max one decimal, e.g. "$184.3K".
- `fullCurrency`: USD with no decimals, e.g. "$184,320".
- `compactNumber`: compact notation, max one decimal, e.g. "1.2K".
- `percent`: `${v.toFixed(0)}%`.
## Companion helpers for cartesian charts
- `type ChartSeries = { key: string; label: string; slot?: number }`.
- `seriesColor(palette, series, index)` returns `categorical[series.slot ?? index]`, capped at the last slot. A chart whose series come and go (a filter, a toggle) should pin `slot` so each colour stays with its series.
- `axisProps(palette)` returns `{ tick: { fontSize: 11, fill: axis }, stroke: grid, tickLine: false }`: solid hairlines and muted ticks, spread onto every XAxis and YAxis.
## Demo
A page of swatch rows for each ramp (categorical, ordinal, sequential, diverging) in the current mode, with a light/dark toggle, and a small Recharts line chart of three series using `seriesColor` and `axisProps`.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: marketing-stats (96S1), verbatim. */
import { useTheme } from '@/contexts/ThemeContext';
// One place for the dashboard's chart colors so every chart shares the same
// categorical palette and adapts axes/grid/tooltip to light vs dark. Hues are
// deliberately distinct (amber / emerald / indigo / blue) and all read on both
// a white and a near-black surface.
export function useChartTheme() {
const { theme } = useTheme();
const dark = theme === 'dark';
return {
dark,
axis: dark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
grid: dark ? '#374151' : '#e5e7eb', // gray-700 / gray-200
tooltip: {
backgroundColor: dark ? '#1f2937' : '#ffffff', // gray-800 / white
border: `1px solid ${dark ? '#374151' : '#e5e7eb'}`,
borderRadius: '0.5rem',
color: dark ? '#f3f4f6' : '#111827',
fontSize: '12px',
} as React.CSSProperties,
series: {
spend: '#f59e0b', // amber-500
deposit: '#10b981', // emerald-500
registrations: '#6366f1', // indigo-500
ftd: '#3b82f6', // blue-500
positive: '#10b981', // emerald-500
negative: '#ef4444', // red-500
retention: '#8b5cf6', // violet-500
retentionD30: '#ec4899', // pink-500
},
};
}
/**
* THE CHART PALETTE — validated, by role.
*
* `categorical` is eight hues in a FIXED order: series take slots in the
* order they are declared, never by rank, so filtering a series out never
* repaints the survivors. The order is the colour-blind-safety mechanism, not
* decoration — each adjacent pair clears ΔE 8 under every CVD simulation.
* Checked with the dataviz skill's validator against this kit's own surfaces
* (white / slate-800), light and dark:
*
* light worst adjacent CVD ΔE 9.1, normal-vision 19.6
* dark worst adjacent CVD ΔE 8.4, normal-vision 19.3
*
* Three light hues (aqua, yellow, magenta) sit under 3:1 on white, so every
* chart built on these carries a legend and a table view — identity never
* rests on colour alone. A scatter, where EVERY pair of series can touch,
* caps at the first three slots (all-pairs validated in both modes).
*
* `ordinal` is the funnel's stage ramp (one hue, visible steps, the end
* nearest the surface still 2:1); `sequential` is the heatmap's (lightest =
* near zero, allowed to recede); `diverging` is blue ↔ red with a grey
* midpoint that reads as "nothing". A ninth series is never a generated
* hue — fold it into "Other".
*/
const CATEGORICAL = {
light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'],
dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'],
};
const ORDINAL = {
light: ['#104281', '#1c5cab', '#2a78d6', '#5598e7', '#86b6ef'],
dark: ['#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf'],
};
const SEQUENTIAL = {
// near zero → most
light: ['#e8f1fd', '#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95', '#0d366b'],
dark: ['#26324a', '#184f95', '#1c5cab', '#256abf', '#3987e5', '#6da7ec', '#9ec5f4', '#cde2fb'],
};
/** The palette for one mode, plus the chrome every chart shares. */
export function chartPalette(dark: boolean) {
const mode = dark ? 'dark' : 'light';
return {
dark,
categorical: CATEGORICAL[mode],
ordinal: ORDINAL[mode],
sequential: SEQUENTIAL[mode],
diverging: { negative: dark ? '#e66767' : '#e34948', positive: dark ? '#3987e5' : '#2a78d6', mid: dark ? '#475569' : '#e2e8f0' },
/** The de-emphasis grey: context series, sparkline history. */
muted: dark ? '#64748b' : '#94a3b8',
/** What a 2px gap between touching marks is drawn in — the card surface. */
surface: dark ? '#1e293b' : '#ffffff',
grid: dark ? '#334155' : '#e2e8f0',
axis: dark ? '#94a3b8' : '#64748b',
ink: dark ? '#f1f5f9' : '#0f172a',
inkSecondary: dark ? '#cbd5e1' : '#475569',
};
}
export type ChartPalette = ReturnType<typeof chartPalette>;
/** The palette for the active colour scheme. */
export function useChartPalette(): ChartPalette {
const { theme } = useTheme();
return chartPalette(theme === 'dark');
}
export const compactCurrency = (v: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(v);
export const fullCurrency = (v: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v);
export const compactNumber = (v: number) =>
new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(v);
export const percent = (v: number) => `${v.toFixed(0)}%`;