AreaChart
Preview
Basic
Loading…
Preview
Code
ts
import AreaChart from '@/components/data/AreaChart';src/components/data/LineChart.tsx
AI prompt
text
Build an area chart component (single or stacked) in React + TypeScript + Tailwind CSS, drawn with Recharts (`AreaChart`, `Area`, `ResponsiveContainer`), for a trend where the filled volume matters.
## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, a legend when there are 2+ series (10px `rounded-sm` square swatch + 11px label), and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`). The table view lists x plus one right-aligned formatted column per series. No rows → a 240px "No data for the selected period." placeholder.
- Plot: default 280px tall, full width, margin top 8 / right 12 / left 0.
- Grid: horizontal hairlines only, #e2e8f0 / dark #334155. Ticks 11px #64748b / dark #94a3b8, no tick marks; x-axis with `minTickGap` 24; y-axis 48px wide with no axis line.
- Each area: monotone curve, a 2px line in the series hue on top of a ~12% opacity wash of the same hue — never a saturated block. The hovered point is an 8px dot in the series colour ringed 2px in the card colour (#ffffff / dark #1e293b).
- `stacked`: the areas stack into a total, so the top edge is the sum and each band is one series' share.
- Series colours from one fixed categorical order — light #2a78d6, #eb6834, #1baf7a, #eda100, #e87ba4, #008300, #4a3aa7, #e34948; dark #3987e5, #d95926, #199e70, #c98500, #d55181, #008300, #9085e9, #e66767. A series uses its `slot` if given, else its declaration index (max eight).
## Behaviour
- Hover: a 1px vertical crosshair in the tick colour snaps to the nearest x; ONE tooltip lists every series there — an opaque floating card, x label (through `xFormat`) in 11px slate-500, then per series a 14×3px colour stroke, the value in semibold tabular-nums, the series name in slate-500.
- Animation off. Default value format: compact number.
- One y-axis only; never a second axis for a measure on a different scale.
## API
`title`, `hint?`, `data: Record<string, string | number>[]`, `x: string`, `series: { key: string; label: string; slot?: number }[]`, `stacked = false`, `format?: (v: number) => string`, `xFormat?: (v: unknown) => string` (default `String`), `height = 280`, `className?`. (It is the line chart's sibling: same props, minus `emphasis`, plus `stacked` — build both on one shared internal chart if you have the line chart too.)
## Demo
"MRR by plan", stacked, with the hint "The top edge is total MRR, each band a plan's share of it": twelve months Oct–Sep, Starter (~$18K–29K), Pro (~$42K–70K) and Enterprise (~$61K–115K) on slots 0–2, formatted as compact currency.
## 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';
import {
Area, AreaChart as RAreaChart, CartesianGrid, Line, LineChart as RLineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import ChartCard, { ChartTooltip } from './ChartCard';
import { axisProps, seriesColor, type ChartSeries } from './chartSeries';
import { compactNumber, useChartPalette } from './chartTheme';
type Common = {
title: string;
hint?: string;
data: Record<string, string | number>[];
/** The x key — usually a date or period label. */
x: string;
series: ChartSeries[];
format?: (v: number) => string;
/** How an x value reads in the tooltip and the table. */
xFormat?: (v: unknown) => string;
height?: number;
className?: string;
};
/**
* Lines for change over time — one or several series on ONE axis.
*
* Two measures of different scale do not share this chart: a second y-axis
* invents a correlation out of where the two scales happen to line up. Give
* each its own chart, or index both to a common base.
*
* Lines are 2px; the hover crosshair snaps to the nearest x and one tooltip
* lists every series there, so the pointer never has to land on a line. The
* active point is an 8px dot ringed in the card's colour so it reads where
* lines cross.
*/
export default function LineChart({ emphasis, ...props }: Common & {
/** Draw this series in colour and the rest in grey — when one line is the point. */
emphasis?: string;
}) {
return <TimeChart {...props} kind="line" emphasis={emphasis} />;
}
/**
* Areas for a trend where the filled volume matters — one series, or several
* stacked into a total. The fill is a ~12% wash of the series hue with the
* 2px line on top, never a saturated block.
*/
export function AreaChart({ stacked = false, ...props }: Common & { stacked?: boolean }) {
return <TimeChart {...props} kind="area" stacked={stacked} />;
}
function TimeChart({
kind,
title,
hint,
data,
x,
series,
stacked = false,
emphasis,
format = compactNumber,
xFormat = (v) => String(v),
height = 280,
className,
}: Common & { kind: 'line' | 'area'; stacked?: boolean; emphasis?: string }) {
const p = useChartPalette();
const axis = axisProps(p);
const colorOf = (s: ChartSeries, i: number) => (emphasis && s.key !== emphasis ? p.muted : seriesColor(p, s, i));
const shared = (
<>
<CartesianGrid vertical={false} stroke={p.grid} />
<XAxis dataKey={x} tickFormatter={(v) => xFormat(v)} minTickGap={24} {...axis} />
<YAxis tickFormatter={format} width={48} {...axis} axisLine={false} />
<Tooltip
cursor={{ stroke: p.axis, strokeWidth: 1 }}
content={(props) => <ChartTooltip {...props} format={format} labelFormat={xFormat} />}
/>
</>
);
const activeDot = (color: string) => ({ r: 4, fill: color, stroke: p.surface, strokeWidth: 2 });
return (
<ChartCard
title={title}
hint={hint}
className={className}
empty={data.length === 0}
legend={series.map((s, i) => ({ label: s.label, color: colorOf(s, i), shape: kind === 'line' ? 'line' : 'rect' }))}
table={{
columns: [
{ key: x, label: x.charAt(0).toUpperCase() + x.slice(1), format: xFormat },
...series.map((s) => ({ key: s.key, label: s.label, align: 'right' as const, format: (v: unknown) => format(Number(v)) })),
],
rows: data,
}}
>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
{kind === 'line' ? (
<RLineChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
{shared}
{series.map((s, i) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={colorOf(s, i)}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
dot={false}
activeDot={activeDot(colorOf(s, i))}
isAnimationActive={false}
/>
))}
</RLineChart>
) : (
<RAreaChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
{shared}
{series.map((s, i) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stackId={stacked ? 'stack' : undefined}
stroke={colorOf(s, i)}
strokeWidth={2}
fill={colorOf(s, i)}
fillOpacity={0.12}
activeDot={activeDot(colorOf(s, i))}
isAnimationActive={false}
/>
))}
</RAreaChart>
)}
</ResponsiveContainer>
</div>
</ChartCard>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
stacked | boolean | false |