Data display
Chart
A themed wrapper for Recharts: series colors from the chart tokens, hairline grids, 12px muted axes, a popover tooltip, and built-in loading and empty states.
npx prodular-ui add chartArea with gradient
One series, a soft gradient fill and the headline number in the card header.
"use client"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Badge } from "@/components/ui/badge"import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig,} from "@/components/ui/chart"
const data = [ { month: "Nov", mrr: 31240 }, { month: "Dec", mrr: 32810 }, { month: "Jan", mrr: 33960 }, { month: "Feb", mrr: 35420 }, { month: "Mar", mrr: 36180 }, { month: "Apr", mrr: 38050 }, { month: "May", mrr: 39720 }, { month: "Jun", mrr: 40610 }, { month: "Jul", mrr: 42380 }, { month: "Aug", mrr: 43940 }, { month: "Sep", mrr: 45370 }, { month: "Oct", mrr: 48290 },]
const config = { mrr: { label: "MRR", color: "var(--chart-1)" },} satisfies ChartConfig
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
export default function ChartArea() { return ( <Card className="w-full"> <CardHeader> <CardDescription>Monthly recurring revenue</CardDescription> <CardTitle className="text-2xl font-semibold tracking-tight tabular-nums">$48,290</CardTitle> <CardAction> <Badge variant="success" size="sm">+54.6% in 12 months</Badge> </CardAction> </CardHeader> <CardContent> <ChartContainer config={config} className="aspect-auto h-60 w-full"> <AreaChart data={data} margin={{ left: 0, right: 4, top: 8 }}> <defs> <linearGradient id="chart-area-mrr" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stopColor="var(--color-mrr)" stopOpacity={0.28} /> <stop offset="100%" stopColor="var(--color-mrr)" stopOpacity={0} /> </linearGradient> </defs> <CartesianGrid vertical={false} /> <XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} /> <YAxis width={44} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={(v: number) => `$${v / 1000}k`} /> <ChartTooltip cursor={{ strokeDasharray: "4 4" }} content={<ChartTooltipContent indicator="line" valueFormatter={(v) => usd.format(Number(v))} />} /> <Area dataKey="mrr" type="monotone" stroke="var(--color-mrr)" strokeWidth={2} fill="url(#chart-area-mrr)" activeDot={{ r: 4, strokeWidth: 2 }} /> </AreaChart> </ChartContainer> </CardContent> </Card> )}Stacked bar
Series without a color take chart-1 to chart-5 in order.
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig,} from "@/components/ui/chart"
const data = [ { month: "May", starter: 420, team: 1180, enterprise: 2240 }, { month: "Jun", starter: 460, team: 1240, enterprise: 2310 }, { month: "Jul", starter: 510, team: 1320, enterprise: 2480 }, { month: "Aug", starter: 490, team: 1410, enterprise: 2620 }, { month: "Sep", starter: 540, team: 1480, enterprise: 2790 }, { month: "Oct", starter: 580, team: 1560, enterprise: 3010 },]
// No colors set: the series take chart-1, chart-2 and chart-3 from the theme.const config = { enterprise: { label: "Enterprise" }, team: { label: "Team" }, starter: { label: "Starter" },} satisfies ChartConfig
export default function ChartStackedBar() { return ( <Card className="w-full"> <CardHeader> <CardTitle>Active seats by plan</CardTitle> <CardDescription>5,150 seats in October, up 34% since May</CardDescription> </CardHeader> <CardContent> <ChartContainer config={config} className="aspect-auto h-64 w-full"> <BarChart data={data} margin={{ left: 0, right: 4, top: 8 }} barCategoryGap="28%"> <CartesianGrid vertical={false} /> <XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} /> <YAxis width={40} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={(v: number) => (v >= 1000 ? `${v / 1000}k` : `${v}`)} /> <ChartTooltip cursor={false} content={<ChartTooltipContent />} /> <ChartLegend content={<ChartLegendContent />} /> <Bar dataKey="enterprise" stackId="seats" fill="var(--color-enterprise)" radius={[0, 0, 3, 3]} /> <Bar dataKey="team" stackId="seats" fill="var(--color-team)" /> <Bar dataKey="starter" stackId="seats" fill="var(--color-starter)" radius={[3, 3, 0, 0]} /> </BarChart> </ChartContainer> </CardContent> </Card> )}Two series
p95 and p50 latency with a reference line for the SLO.
"use client"
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig,} from "@/components/ui/chart"
const p50 = [62, 58, 55, 54, 57, 66, 81, 94, 102, 98, 96, 101, 97, 92, 95, 104, 110, 99, 88, 79, 72, 68, 65, 63]const p95 = [148, 139, 131, 128, 136, 162, 204, 241, 268, 252, 247, 263, 255, 238, 246, 281, 312, 266, 229, 198, 181, 170, 161, 152]
const data = p50.map((v, hour) => ({ hour: `${String(hour).padStart(2, "0")}:00`, p50: v, p95: p95[hour],}))
const config = { p95: { label: "p95", color: "var(--chart-1)" }, p50: { label: "p50", color: "var(--chart-2)" },} satisfies ChartConfig
export default function ChartLine() { return ( <Card className="w-full"> <CardHeader> <CardTitle>API latency</CardTitle> <CardDescription>Last 24 hours, all regions. SLO is 300 ms at p95.</CardDescription> </CardHeader> <CardContent> <ChartContainer config={config} className="aspect-auto h-64 w-full"> <LineChart data={data} margin={{ left: 0, right: 4, top: 12 }}> <CartesianGrid vertical={false} /> <XAxis dataKey="hour" tickLine={false} axisLine={false} tickMargin={8} interval={3} /> <YAxis width={64} domain={[0, 400]} ticks={[0, 100, 200, 300, 400]} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={(v: number) => `${v} ms`} /> <ReferenceLine y={300} strokeDasharray="4 4" /> <ChartTooltip content={<ChartTooltipContent valueFormatter={(v) => `${v} ms`} />} /> <ChartLegend content={<ChartLegendContent />} /> <Line dataKey="p95" type="monotone" stroke="var(--color-p95)" strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 2 }} /> <Line dataKey="p50" type="monotone" stroke="var(--color-p50)" strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 2 }} /> </LineChart> </ChartContainer> </CardContent> </Card> )}Horizontal bars with labels
layout="vertical" for long category names, values printed at the end of each bar with LabelList.
"use client"
import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
const data = [ { feature: "Invoices", users: 4120 }, { feature: "Reminders", users: 3380 }, { feature: "Payment links", users: 2710 }, { feature: "Reports", users: 1940 }, { feature: "Copilot", users: 1520 }, { feature: "API", users: 610 },]
const config = { users: { label: "Weekly users", color: "var(--chart-1)" },} satisfies ChartConfig
export default function ChartBarHorizontal() { return ( <Card className="w-full"> <CardHeader> <CardTitle>Feature adoption</CardTitle> <CardDescription>Weekly active users per feature</CardDescription> </CardHeader> <CardContent> <ChartContainer config={config} className="aspect-auto h-64 w-full"> <BarChart data={data} layout="vertical" margin={{ left: 0, right: 48 }}> <YAxis dataKey="feature" type="category" tickLine={false} axisLine={false} width={104} tickMargin={4} /> <XAxis type="number" hide /> <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} /> <Bar dataKey="users" fill="var(--color-users)" radius={4} barSize={22}> <LabelList dataKey="users" position="right" offset={8} className="fill-foreground font-medium tabular-nums" formatter={(v) => Number(v).toLocaleString("en-US")} /> </Bar> </BarChart> </ChartContainer> </CardContent> </Card> )}Bars and a line on two axes
ComposedChart with an amount on the left axis and a rate on the right.
"use client"
import { Bar, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig,} from "@/components/ui/chart"
const data = [ { month: "May", invoiced: 412000, collected: 0.86 }, { month: "Jun", invoiced: 448000, collected: 0.88 }, { month: "Jul", invoiced: 431000, collected: 0.84 }, { month: "Aug", invoiced: 486000, collected: 0.9 }, { month: "Sep", invoiced: 502000, collected: 0.92 }, { month: "Oct", invoiced: 538000, collected: 0.94 },]
const config = { invoiced: { label: "Invoiced", color: "var(--chart-1)" }, collected: { label: "Collected on time", color: "var(--chart-3)" },} satisfies ChartConfig
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
export default function ChartCombo() { return ( <Card className="w-full"> <CardHeader> <CardTitle>Invoiced and collected</CardTitle> <CardDescription>Bars on the left axis, the on-time rate on the right</CardDescription> </CardHeader> <CardContent> <ChartContainer config={config} className="aspect-auto h-64 w-full"> <ComposedChart data={data} margin={{ left: 0, right: 0, top: 8 }}> <CartesianGrid vertical={false} /> <XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} /> <YAxis yAxisId="amount" width={44} tickLine={false} axisLine={false} tickFormatter={(v: number) => `$${v / 1000}k`} /> <YAxis yAxisId="rate" orientation="right" width={40} domain={[0.6, 1]} tickLine={false} axisLine={false} tickFormatter={(v: number) => `${Math.round(v * 100)}%`} /> <ChartTooltip content={ <ChartTooltipContent valueFormatter={(v) => (Number(v) <= 1 ? `${Math.round(Number(v) * 100)}%` : usd.format(Number(v)))} /> } /> <ChartLegend content={<ChartLegendContent />} /> <Bar yAxisId="amount" dataKey="invoiced" fill="var(--color-invoiced)" radius={[4, 4, 0, 0]} barSize={28} fillOpacity={0.85} /> <Line yAxisId="rate" dataKey="collected" type="monotone" stroke="var(--color-collected)" strokeWidth={2} dot={{ r: 3, fill: "var(--color-collected)" }} /> </ComposedChart> </ChartContainer> </CardContent> </Card> )}Radial progress
RadialBarChart against a 0 to 100 domain for progress toward goals, with a plain legend beside it.
"use client"
import { PolarAngleAxis, RadialBar, RadialBarChart } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
const goals = [ { goal: "New ARR", value: 82, fill: "var(--chart-1)" }, { goal: "Expansion", value: 64, fill: "var(--chart-2)" }, { goal: "Net retention", value: 91, fill: "var(--chart-3)" },]
// Recharts draws the first item innermost, so reverse to match the legend from the outside in.const rings = [...goals].reverse()
const config = { arr: { label: "New ARR", color: "var(--chart-1)" }, expansion: { label: "Expansion", color: "var(--chart-2)" }, retention: { label: "Net retention", color: "var(--chart-3)" },} satisfies ChartConfig
export default function ChartRadial() { return ( <Card className="w-full max-w-md"> <CardHeader> <CardTitle>Q4 goals</CardTitle> <CardDescription>Progress toward each target, 41 days left</CardDescription> </CardHeader> <CardContent className="flex items-center gap-6"> <ChartContainer config={config} className="aspect-square h-44 shrink-0"> <RadialBarChart data={rings} innerRadius="38%" outerRadius="100%" startAngle={90} endAngle={-270} barSize={12}> <PolarAngleAxis type="number" domain={[0, 100]} tick={false} axisLine={false} /> <RadialBar dataKey="value" background cornerRadius={6} /> </RadialBarChart> </ChartContainer> <ul className="flex flex-1 flex-col gap-3 text-sm"> {goals.map((g) => ( <li key={g.goal} className="flex items-center gap-2.5"> <span aria-hidden="true" className="size-2.5 rounded-full" style={{ backgroundColor: g.fill }} /> <span className="flex-1 text-muted-foreground">{g.goal}</span> <span className="font-medium tabular-nums">{g.value}%</span> </li> ))} </ul> </CardContent> </Card> )}Loading and empty
loading draws skeleton bars, loading="line" an area silhouette, empty a message over the grid. The card keeps its height in every state.
"use client"
import { Bar, BarChart } from "recharts"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
const config = { value: { label: "Value" } } satisfies ChartConfig
export default function ChartStates() { return ( <div className="grid w-full gap-4 md:grid-cols-3"> <Card size="sm"> <CardHeader> <CardDescription>Revenue, loading</CardDescription> <CardTitle>Bars</CardTitle> </CardHeader> <CardContent> <ChartContainer config={config} loading className="aspect-auto h-40 w-full"> <BarChart data={[]}> <Bar dataKey="value" /> </BarChart> </ChartContainer> </CardContent> </Card> <Card size="sm"> <CardHeader> <CardDescription>Latency, loading</CardDescription> <CardTitle>Line</CardTitle> </CardHeader> <CardContent> <ChartContainer config={config} loading="line" className="aspect-auto h-40 w-full"> <BarChart data={[]}> <Bar dataKey="value" /> </BarChart> </ChartContainer> </CardContent> </Card> <Card size="sm"> <CardHeader> <CardDescription>Refunds, last 7 days</CardDescription> <CardTitle>Empty</CardTitle> </CardHeader> <CardContent> <ChartContainer config={config} empty className="aspect-auto h-40 w-full"> <BarChart data={[]}> <Bar dataKey="value" /> </BarChart> </ChartContainer> </CardContent> </Card> </div> )}Anatomy
const config = { mrr: { label: "MRR" } } satisfies ChartConfig
<ChartContainer config={config} className="aspect-auto h-64 w-full"> <AreaChart data={data}> <CartesianGrid vertical={false} /> <XAxis dataKey="month" tickLine={false} axisLine={false} /> <ChartTooltip content={<ChartTooltipContent />} /> <Area dataKey="mrr" stroke="var(--color-mrr)" fill="var(--color-mrr)" /> </AreaChart></ChartContainer>Guidance
Use it when
- Trends over time: area for one cumulative metric, lines for comparing two or three series.
- Composition over time: stacked bars for parts of a total, such as seats by plan.
- Put every chart in a Card with the metric name and the current value in the header.
- Horizontal bars when category names are long or there are more than six categories.
- A combo chart only when the two measures explain each other, like amount and rate.
Avoid
- More than five series. Group the rest into Other.
- Pie charts for more than three parts. Use a stacked bar.
- Raw colors in series. Use var(--chart-N) or leave color out of the config.
- A spinner or blank card while data loads. Use loading so the layout does not jump.
- Two axes for measures that share a unit. Put them on one axis.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Wrap every Recharts chart in ChartContainer with a config. Reference series colors as var(--color-<key>), never as hex or Tailwind palette colors.
- Leave color out of the config to get the theme chart tokens in order, or set color: "var(--chart-2)" to pick one.
- Give ChartContainer an explicit height: className="aspect-auto h-64 w-full".
- Use <CartesianGrid vertical={false} /> and hide axis and tick lines (axisLine={false} tickLine={false}).
- Format axis ticks short ($40k, 200 ms) with tickFormatter and tooltip values in full with valueFormatter on ChartTooltipContent.
- Add ChartLegend with ChartLegendContent when there are two or more series.
- Use a linearGradient from the series color at 0.28 opacity to 0 for area fills. Give each gradient a unique id.
- While fetching, pass loading (bars) or loading="line" to ChartContainer and keep the chart children; they render once loading is false.
- When the query returns no rows, pass empty, or a node with your own sentence, instead of rendering an empty chart.
- For progress toward targets use RadialBarChart with PolarAngleAxis domain [0, 100] and background on RadialBar.
API
ChartContainer
| Prop | Type | Default | Description |
|---|---|---|---|
| config | ChartConfig | Labels, icons and colors per series key. Sets --color-<key> for each. | |
| loading | boolean | "bars" | "line" | false | Shows a skeleton in the chart's place. |
| empty | boolean | ReactNode | false | Shows the empty state. Pass a node for your own message. |
ChartTooltipContent
| Prop | Type | Default | Description |
|---|---|---|---|
| indicator | "dot" | "line" | "dashed" | "dot" | Series marker in the tooltip. |
| valueFormatter | (value) => ReactNode | Formats each value, for example as currency. | |
| hideLabel | boolean | false | Hides the x value heading. |
ChartLegendContent
| Prop | Type | Default | Description |
|---|---|---|---|
| nameKey | string | Payload key to look up labels in the config. |