Use ChartContainer around a Recharts chart to supply series colors and labels. The wrapper is not a charting engine or a data source. The chart gallery contains eight independently installable analytical recipes with controls, calculated summaries, and exact data tables. All sample data is illustrative.
The preview below is the Release adoption recipe. Install that recipe for the complete screen; installing Chart alone supplies the primitives.
"use client";
import { useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartSelect,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const releases = [
{ release: "R1", installs: 148, updates: 62 },
{ release: "R2", installs: 232, updates: 104 },
{ release: "R3", installs: 196, updates: 88 },
{ release: "R4", installs: 284, updates: 132 },
{ release: "R5", installs: 341, updates: 176 },
{ release: "R6", installs: 378, updates: 209 },
];
const config = {
installs: { label: "New installs", color: "var(--chart-1)" },
updates: {
label: "Updates",
color: "color-mix(in srgb, var(--chart-2) 65%, var(--foreground))",
},
} satisfies ChartConfig;
const number = new Intl.NumberFormat("en-US");
export default function ChartReleaseActivity() {
const [view, setView] = useState("count");
const [range, setRange] = useState("6");
const data = releases.slice(-Number(range));
const installs = data.reduce((total, row) => total + row.installs, 0);
const updates = data.reduce((total, row) => total + row.updates, 0);
const total = installs + updates;
const share = view === "share";
return (
<Card className="min-w-0" data-chart-recipe="activity">
<CardHeader>
<p className="font-mono text-xs uppercase tracking-widest">
Activity / Volume or composition?
</p>
<CardTitle>
<h3>Release adoption</h3>
</CardTitle>
<CardDescription>
Separate new adoption from repeat use. Illustrative event counts, not unique users.
</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-6">
<div className="flex flex-wrap gap-4">
<ChartSelect
label="Activity measure"
value={view}
onValueChange={setView}
options={[
{ value: "count", label: "Event counts" },
{ value: "share", label: "Share of each release" },
]}
/>
<ChartSelect
label="Release period"
value={range}
onValueChange={setRange}
options={[
{ value: "6", label: "All 6 releases" },
{ value: "3", label: "Latest 3 releases" },
]}
/>
</div>
<dl className="grid gap-4 border-y-2 border-border py-4 sm:grid-cols-3" aria-live="polite">
{[
["Total activity", number.format(total)],
["New installs", number.format(installs)],
["Update share", `${((updates / total) * 100).toFixed(1)}%`],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs">{label}</dt>
<dd className="mt-1 text-2xl font-heading tabular-nums">{value}</dd>
</div>
))}
</dl>
<figure
aria-label={
share ? "Install and update share by release" : "Install and update events by release"
}
className="min-w-0"
>
<ChartContainer config={config} className="h-72 w-full min-w-0 aspect-auto">
<AreaChart
accessibilityLayer
data={data}
stackOffset={share ? "expand" : "none"}
margin={{ top: 12, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid vertical={false} />
<XAxis dataKey="release" tickLine={false} axisLine={false} tickMargin={10} />
<YAxis
width={48}
domain={share ? [0, 1] : [0, "auto"]}
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
share ? `${Math.round(Number(value) * 100)}%` : number.format(Number(value))
}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) =>
`${name === "installs" ? "New installs" : "Updates"}: ${number.format(Number(value))} events`
}
/>
}
/>
<Area
dataKey="installs"
type="linear"
stackId="events"
fill="var(--color-installs)"
fillOpacity={0.7}
stroke="var(--foreground)"
strokeWidth={2}
isAnimationActive={false}
/>
<Area
dataKey="updates"
type="linear"
stackId="events"
fill="var(--color-updates)"
fillOpacity={0.6}
stroke="var(--foreground)"
strokeWidth={2}
strokeDasharray="6 4"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
<figcaption className="mt-3 text-xs leading-5">
Lower area / solid boundary: new installs. Upper area / dashed boundary: updates.
</figcaption>
</figure>
<output className="border-l-4 border-main pl-3 text-sm leading-6">
{share
? "Every release totals 100%. A larger share does not necessarily mean more events."
: "Stack height is total activity. The solid and dashed boundaries separate installs and updates."}
</output>
<details className="min-w-0 border-t-2 border-border pt-4">
<summary className="cursor-pointer text-sm font-heading">View activity data</summary>
<table className="mt-3 w-full text-left text-xs tabular-nums">
<caption className="pb-3 text-left">
The same {data.length} releases. Update share uses each release's total.
</caption>
<thead>
<tr>
{["Release", "Installs", "Updates", "Update share"].map((label) => (
<th key={label} scope="col" className="border-b-2 border-border py-2 pr-2">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.release}>
<th scope="row" className="py-3 pr-2">
{row.release}
</th>
<td>{row.installs}</td>
<td>{row.updates}</td>
<td>{((row.updates / (row.installs + row.updates)) * 100).toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</details>
</CardContent>
</Card>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/chart.jsonnpm install recharts@^3.9.2Also install Select, then create components/ui/chart.tsx:
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const CHART_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CHART_COLOR_PATTERN = /^[\w\s#(),.%+\-/]+$/;
const SAFE_COLOR_FUNCTIONS = new Set([
"calc",
"clamp",
"color",
"color-mix",
"contrast-color",
"hsl",
"hsla",
"hwb",
"lab",
"lch",
"light-dark",
"max",
"min",
"oklab",
"oklch",
"rgb",
"rgba",
"var",
]);
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const chartId = normalizeChartId(`chart-${id ?? uniqueId}`);
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex min-w-[232px] aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick-value]:fill-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-[#80808080] [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[#80808080] [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-bar-rectangle]:stroke-border [&_.recharts-bar-rectangle]:stroke-2 [&_.recharts-sector]:stroke-border [&_.recharts-sector]:stroke-2 [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-black [&_.recharts-polar-grid_[stroke='#ccc']]:dark:stroke-white [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-foreground/10 [&_.recharts-reference-line_[stroke='#ccc']]:stroke-black [&_.recharts-reference-line_[stroke='#ccc']]:dark:stroke-white [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-border [&_.recharts-surface]:outline-hidden [&_.recharts-surface:focus-visible]:outline-2 [&_.recharts-surface:focus-visible]:outline-ring",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const css = Object.entries(THEMES)
.flatMap(([theme, prefix]) => {
const variables = Object.entries(config).flatMap(([key, itemConfig]) => {
if (!CHART_KEY_PATTERN.test(key)) {
return [];
}
const color = itemConfig.theme?.[theme as keyof typeof THEMES] ?? itemConfig.color;
if (!isSafeChartColor(color)) {
return [];
}
return [` --color-${key}: ${color.trim()};`];
});
if (!variables.length) {
return [];
}
return [`${prefix} [data-chart="${normalizeChartId(id)}"] {\n${variables.join("\n")}\n}`];
})
.join("\n");
if (!css) {
return null;
}
return <style>{css}</style>;
};
function normalizeChartId(id: string) {
return id.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function isSafeChartColor(color: string | undefined): color is string {
if (!color || color.length > 256 || !CHART_COLOR_PATTERN.test(color)) {
return false;
}
for (const [, name] of color.matchAll(/([a-zA-Z-]+)\s*\(/g)) {
if (!SAFE_COLOR_FUNCTIONS.has(name.toLowerCase())) {
return false;
}
}
return true;
}
const ChartTooltip = RechartsPrimitive.Tooltip;
type ChartContentHtmlProps = Omit<
React.ComponentProps<"div">,
"children" | "dangerouslySetInnerHTML"
> & { [attribute: `data-${string}`]: string | number | boolean | null | undefined };
type ChartTooltipContentProps = Partial<
Pick<
RechartsPrimitive.TooltipContentProps<TooltipValueType, TooltipNameType>,
"active" | "payload" | "label" | "labelFormatter" | "formatter" | "accessibilityLayer"
>
> & {
className?: string;
htmlProps?: ChartContentHtmlProps;
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
labelClassName?: string;
color?: string;
nameKey?: string;
labelKey?: string;
};
function ChartTooltipContent({
active,
payload,
className,
htmlProps,
accessibilityLayer,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: ChartTooltipContentProps) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && (typeof label === "string" || typeof label === "number")
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (value === undefined || value === null || value === false || value === "") {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
role={accessibilityLayer ? "status" : undefined}
aria-live={accessibilityLayer ? "assertive" : undefined}
{...htmlProps}
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-base border-2 border-border bg-background px-2.5 py-1.5 text-xs font-base shadow-shadow",
className,
htmlProps?.className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
if (item.type === "none") return null;
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
let formatted: React.ReactNode;
if (formatter && item.value !== undefined && item.name != null) {
formatted = formatter(item.value, item.name, item, index, payload);
if (formatted == null) return null;
}
const formattedTuple = Array.isArray(formatted) ? formatted : undefined;
const displayName = formattedTuple ? formattedTuple[1] : (itemConfig?.label ?? item.name);
const displayValue = formattedTuple
? formattedTuple[0]
: item.value == null
? null
: typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value);
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-foreground",
indicator === "dot" && "items-center",
)}
>
{formatted !== undefined && !formattedTuple ? (
formatted
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-foreground">{displayName}</span>
</div>
{displayValue != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{displayValue}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
htmlProps,
}: Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
className?: string;
htmlProps?: ChartContentHtmlProps;
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
{...htmlProps}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
htmlProps?.className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label ?? item.value}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartSelect,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
function ChartSelect({
label,
value,
onValueChange,
options,
}: {
label: string;
value: string;
onValueChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
const id = React.useId();
return (
<div className="grid w-full min-w-0 gap-2 sm:w-auto sm:min-w-44 sm:max-w-72">
<label htmlFor={id} className="text-xs font-heading">
{label}
</label>
<Select
value={value}
onValueChange={(nextValue) => {
// This fixed-option control has no cleared selection to expose.
if (nextValue !== null) onValueChange(nextValue);
}}
items={options}
>
<SelectTrigger
id={id}
className="h-11 min-w-0 bg-secondary-background font-heading text-foreground shadow-shadow data-popup-open:translate-x-0.5 data-popup-open:translate-y-0.5 data-popup-open:shadow-none [&>svg]:box-content [&>svg]:border-l-2 [&>svg]:border-border [&>svg]:py-1 [&>svg]:pl-3 [&>svg]:opacity-100"
>
<SelectValue />
</SelectTrigger>
<SelectContent
align="start"
sideOffset={8}
collisionPadding={12}
className="max-h-[min(20rem,var(--available-height))] w-(--anchor-width) max-w-(--available-width) bg-secondary-background p-1 text-foreground shadow-shadow [&_[data-slot=select-scroll-up-button]]:bg-secondary-background [&_[data-slot=select-scroll-down-button]]:bg-secondary-background"
>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="min-h-10 cursor-pointer py-2 data-highlighted:border-transparent data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-4 data-highlighted:outline-foreground data-selected:data-highlighted:outline-main-foreground data-selected:bg-main data-selected:font-heading data-selected:text-main-foreground [&>span:first-child]:min-w-0 [&>span:first-child]:shrink [&>span:first-child]:whitespace-normal"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
#Usage
"use client";
import { Bar, BarChart, XAxis } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const data = [
{ month: "Jan", visits: 120 },
{ month: "Feb", visits: 180 },
];
const config = { visits: { label: "Visits", color: "var(--chart-1)" } } satisfies ChartConfig;
export default function VisitsChart() {
return (
<figure className="min-w-0">
<figcaption>Visits increased from 120 in January to 180 in February.</figcaption>
<ChartContainer config={config} className="h-64 w-full min-w-0 aspect-auto">
<BarChart accessibilityLayer data={data}>
<XAxis dataKey="month" />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="visits" fill="var(--color-visits)" />
</BarChart>
</ChartContainer>
</figure>
);
}#Responsive layout
Give the surrounding layout an explicit flexible column, such as grid grid-cols-1, rather than an implicit auto column. A wide table in an automatic column can stretch every neighboring chart beyond a narrow viewport. For multiple columns, use tracks with a zero minimum, such as minmax(0, 1fr), and min-w-0 on nested grid or flex items.
Keep wide data tables in their own keyboard-accessible scroll area. Do not hide page overflow or shrink all text to fit; the plot and its description should reflow while exact table values remain reachable.
#API reference
| Prop / part | Purpose | Default |
|---|---|---|
ChartContainer.config | Series labels and colors checked with ChartConfig | Required |
ChartContainer.children | A Recharts chart | Required |
className | Chart height and responsive width | Default aspect ratio |
ChartTooltip / ChartTooltipContent | Tooltip primitive and styled content | — |
ChartLegend / ChartLegendContent | Legend primitive and styled content | — |
ChartTooltipContent.indicator | dot, line, or dashed | dot |
ChartTooltipContent.formatter | Recharts callback returning a [formattedValue, formattedName] tuple or complete row content | Default series name and value |
ChartTooltipContent.htmlProps / ChartLegendContent.htmlProps | Native attributes, styles, events, and ref for the content's outer div | Unset |
ChartContainer.initialDimension | Initial responsive width and height before measurement, in pixels | { width: 320, height: 200 } |
ChartTooltipContent.hideLabel / hideIndicator | Hide the tooltip heading or series indicators | false |
ChartLegendContent.hideIcon | Hide series icons in the legend | false |
ChartLegendContent.verticalAlign | Position legend spacing above or below the chart | bottom |
Return [formattedValue, formattedName] to keep the styled indicator, name, and value layout. Both parts can contain JSX; zero and empty names are preserved. A JSX or other non-array return replaces the complete row content. Returning null or undefined omits that row. The callback receives the original payload entry, index, and complete payload:
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => [`${Number(value).toLocaleString()} visits`, "Visits"]}
/>
}
/>;Pass DOM attributes through htmlProps when customizing tooltip or legend content. Keep className on the content component for appearance. Recharts supplies its own content props, so separating HTML props keeps chart configuration off the DOM:
import {
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
<ChartTooltip
content={
<ChartTooltipContent
className="max-w-xs"
htmlProps={{ id: "visits-tooltip", "aria-label": "Monthly visits" }}
/>
}
/>;
<ChartLegend
content={<ChartLegendContent htmlProps={{ "aria-label": "Visit series" }} />}
/>;#Accessibility
Set a definite chart height so responsive measurement can work. Give each series a meaningful label and do not rely on color alone. Enable Recharts accessibility support and provide a textual summary or data table for important values. Tooltips alone are not an accessible substitute for the underlying data.