Skip to content

Chart

Themed, accessible charts built on Recharts, with a validated categorical palette, quiet chrome and tooltips that match the system.

SourceRechartsRegistry
Visitors this week27,390
"use client"

import { Area, AreaChart } from "recharts"

import { ChartContainer, type ChartConfig } from "@/components/ui/chart"

const data = [
  { day: "Mon", visitors: 3120 },
  { day: "Tue", visitors: 3480 },
  { day: "Wed", visitors: 3290 },
  { day: "Thu", visitors: 4010 },
  { day: "Fri", visitors: 4620 },
  { day: "Sat", visitors: 3890 },
  { day: "Sun", visitors: 4980 },
]

const config = {
  visitors: { label: "Visitors", color: "var(--chart-1)" },
} satisfies ChartConfig

export default function ChartDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-5 shadow-raised">
      <div className="flex flex-col gap-1">
        <span className="text-body-xs text-muted-foreground">
          Visitors this week
        </span>
        <span className="text-heading-lg">27,390</span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-20 w-full">
        <AreaChart
          data={data}
          margin={{ top: 4, right: 4, bottom: 4, left: 4 }}
        >
          <Area
            dataKey="visitors"
            type="monotone"
            stroke="var(--color-visitors)"
            strokeWidth={2}
            fill="var(--color-visitors)"
            fillOpacity={0.1}
            isAnimationActive={false}
          />
        </AreaChart>
      </ChartContainer>
    </div>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/chart.json

Usage

A chart is Recharts inside a ChartContainer. The container takes a config that names each series and assigns its color; every series color becomes a CSS variable, --color-<key>, scoped to that chart.

import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"

import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const config = {
  visitors: { label: "Visitors", color: "var(--chart-1)" },
} satisfies ChartConfig

<ChartContainer config={config} className="aspect-auto h-64 w-full">
  <AreaChart data={data}>
    <CartesianGrid vertical={false} />
    <XAxis dataKey="day" tickLine={false} axisLine={false} />
    <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
    <Area
      dataKey="visitors"
      stroke="var(--color-visitors)"
      strokeWidth={2}
      fill="var(--color-visitors)"
      fillOpacity={0.1}
    />
  </AreaChart>
</ChartContainer>

Give the container an explicit height that includes the axis labels — aspect-auto h-64 — so the axes never get clipped into a nested scroll.

Examples

Area

A single series tells one story, so it needs no legend: the title names it. Draw a 2px line with a 10% wash of the same color underneath — a quiet fill, never a solid block.

API requests
Up 108% year over year.
"use client"

import { TrendingUpIcon } from "lucide-react"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { month: "Oct", requests: 1.82 },
  { month: "Nov", requests: 1.94 },
  { month: "Dec", requests: 2.11 },
  { month: "Jan", requests: 2.03 },
  { month: "Feb", requests: 2.38 },
  { month: "Mar", requests: 2.56 },
  { month: "Apr", requests: 2.49 },
  { month: "May", requests: 2.81 },
  { month: "Jun", requests: 3.07 },
  { month: "Jul", requests: 3.02 },
  { month: "Aug", requests: 3.36 },
  { month: "Sep", requests: 3.78 },
]

const config = {
  requests: { label: "Requests (millions)", color: "var(--chart-1)" },
} satisfies ChartConfig

export default function ChartArea() {
  return (
    <Card className="w-full max-w-2xl">
      <CardHeader>
        <CardTitle>API requests</CardTitle>
        <CardDescription>
          Monthly requests, in millions, over the last 12 months.
        </CardDescription>
      </CardHeader>
      <CardContent>
        <ChartContainer config={config} className="aspect-auto h-64 w-full">
          <AreaChart data={data} margin={{ left: 0, right: 8, top: 8 }}>
            <CartesianGrid vertical={false} />
            <XAxis
              dataKey="month"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
            />
            <YAxis
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              width={48}
              domain={[0, 4]}
              ticks={[0, 1, 2, 3, 4]}
              tickFormatter={(value: number) => `${value}M`}
            />
            <ChartTooltip
              cursor
              content={<ChartTooltipContent indicator="line" />}
            />
            <Area
              dataKey="requests"
              type="monotone"
              stroke="var(--color-requests)"
              strokeWidth={2}
              fill="var(--color-requests)"
              fillOpacity={0.1}
              activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--card)" }}
            />
          </AreaChart>
        </ChartContainer>
      </CardContent>
      <CardFooter className="gap-2 text-sm">
        <TrendingUpIcon className="size-4 text-success-11" aria-hidden="true" />
        <span>
          <span className="font-medium">Up 108%</span>{" "}
          <span className="text-muted-foreground">year over year.</span>
        </span>
      </CardFooter>
    </Card>
  )
}

Line

With two or more series, a legend is always present. Label the end of each line with its latest value instead of putting a number on every point.

Weekly active users by platformLast 8 weeks
"use client"

import {
  CartesianGrid,
  LabelList,
  Line,
  LineChart,
  XAxis,
  YAxis,
} from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { week: "W1", web: 4210, ios: 2680, android: 1940 },
  { week: "W2", web: 4390, ios: 2810, android: 2120 },
  { week: "W3", web: 4280, ios: 3050, android: 2260 },
  { week: "W4", web: 4620, ios: 3210, android: 2410 },
  { week: "W5", web: 4810, ios: 3390, android: 2690 },
  { week: "W6", web: 4760, ios: 3620, android: 2880 },
  { week: "W7", web: 5040, ios: 3890, android: 3170 },
  { week: "W8", web: 5220, ios: 4010, android: 3420 },
]

const config = {
  web: { label: "Web", color: "var(--chart-1)" },
  ios: { label: "iOS", color: "var(--chart-2)" },
  android: { label: "Android", color: "var(--chart-3)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("en-US")

/** Labels only the last point of each line — selective, never every point. */
function EndLabel(props: {
  x?: number | string
  y?: number | string
  index?: number
  value?: number | string
}) {
  if (props.index !== data.length - 1) return null
  return (
    <text
      x={Number(props.x) + 8}
      y={Number(props.y)}
      dy="0.32em"
      className="fill-foreground text-xs font-medium"
    >
      {number.format(Number(props.value))}
    </text>
  )
}

export default function ChartLine() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">Weekly active users by platform</span>
        <span className="text-body-xs text-muted-foreground">Last 8 weeks</span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-72 w-full">
        <LineChart data={data} margin={{ left: 0, right: 48, top: 12 }}>
          <CartesianGrid vertical={false} />
          <XAxis
            dataKey="week"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
          />
          <YAxis
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            width={52}
            tickFormatter={(value: number) => number.format(value)}
          />
          <ChartTooltip
            cursor
            content={<ChartTooltipContent indicator="line" />}
          />
          <ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
          {(["web", "ios", "android"] as const).map((key) => (
            <Line
              key={key}
              dataKey={key}
              type="monotone"
              stroke={`var(--color-${key})`}
              strokeWidth={2}
              strokeLinecap="round"
              dot={false}
              activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--background)" }}
            >
              <LabelList dataKey={key} content={<EndLabel />} />
            </Line>
          ))}
        </LineChart>
      </ChartContainer>
    </div>
  )
}

Bar

Bars are at most 24px thick, grow from one baseline, and round only their data end (4px). A 2px gap separates bars that touch.

Sign-ups and activationsAccounts per month, 2026
"use client"

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { month: "Apr", signups: 1840, activated: 1120 },
  { month: "May", signups: 2130, activated: 1390 },
  { month: "Jun", signups: 1960, activated: 1310 },
  { month: "Jul", signups: 2480, activated: 1720 },
  { month: "Aug", signups: 2710, activated: 1960 },
  { month: "Sep", signups: 3020, activated: 2290 },
]

const config = {
  signups: { label: "Sign-ups", color: "var(--chart-1)" },
  activated: { label: "Activated", color: "var(--chart-2)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("en-US")

export default function ChartBar() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">Sign-ups and activations</span>
        <span className="text-body-xs text-muted-foreground">
          Accounts per month, 2026
        </span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-72 w-full">
        <BarChart
          data={data}
          barGap={2}
          barCategoryGap="28%"
          margin={{ left: 0, right: 8, top: 12 }}
        >
          <CartesianGrid vertical={false} />
          <XAxis
            dataKey="month"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
          />
          <YAxis
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            width={52}
            tickFormatter={(value: number) => number.format(value)}
          />
          <ChartTooltip content={<ChartTooltipContent />} />
          <ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
          <Bar
            dataKey="signups"
            fill="var(--color-signups)"
            radius={[4, 4, 0, 0]}
            maxBarSize={24}
          />
          <Bar
            dataKey="activated"
            fill="var(--color-activated)"
            radius={[4, 4, 0, 0]}
            maxBarSize={24}
          />
        </BarChart>
      </ChartContainer>
    </div>
  )
}

Stacked

For part-to-whole comparisons across many categories, stack horizontally. A 2px stroke in the surface color separates segments — the gap does the separating, not an outline.

Build minutes by environmentSeptember 2026, per team
"use client"

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { team: "Platform", production: 3120, preview: 1840, development: 620 },
  { team: "Web", production: 2410, preview: 2260, development: 910 },
  { team: "Mobile", production: 1680, preview: 1190, development: 1340 },
  { team: "Data", production: 2890, preview: 640, development: 480 },
  { team: "Growth", production: 1240, preview: 1580, development: 390 },
]

const config = {
  production: { label: "Production", color: "var(--chart-1)" },
  preview: { label: "Preview", color: "var(--chart-2)" },
  development: { label: "Development", color: "var(--chart-3)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("en-US")
const keys = ["production", "preview", "development"] as const

export default function ChartStacked() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">Build minutes by environment</span>
        <span className="text-body-xs text-muted-foreground">
          September 2026, per team
        </span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-72 w-full">
        <BarChart
          data={data}
          layout="vertical"
          margin={{ left: 0, right: 12, top: 12 }}
        >
          <CartesianGrid horizontal={false} />
          <YAxis
            dataKey="team"
            type="category"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            width={68}
          />
          <XAxis
            type="number"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            tickFormatter={(value: number) => number.format(value)}
          />
          <ChartTooltip content={<ChartTooltipContent />} />
          <ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
          {keys.map((key, index) => (
            <Bar
              key={key}
              dataKey={key}
              stackId="minutes"
              fill={`var(--color-${key})`}
              // The surface-colored stroke is the 2px gap between segments.
              stroke="var(--background)"
              strokeWidth={2}
              maxBarSize={24}
              radius={index === keys.length - 1 ? [0, 4, 4, 0] : 0}
            />
          ))}
        </BarChart>
      </ChartContainer>
    </div>
  )
}

Donut

Donuts show part-to-whole at a glance for up to six segments. For comparing close values, use a bar chart — angles are hard to compare.

Traffic by source
"use client"

import { Label, Pie, PieChart } from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { source: "organic", visits: 18420, fill: "var(--color-organic)" },
  { source: "direct", visits: 11260, fill: "var(--color-direct)" },
  { source: "referral", visits: 6890, fill: "var(--color-referral)" },
  { source: "social", visits: 4310, fill: "var(--color-social)" },
]

const config = {
  visits: { label: "Visits" },
  organic: { label: "Organic search", color: "var(--chart-1)" },
  direct: { label: "Direct", color: "var(--chart-2)" },
  referral: { label: "Referral", color: "var(--chart-3)" },
  social: { label: "Social", color: "var(--chart-4)" },
} satisfies ChartConfig

const total = data.reduce((sum, item) => sum + item.visits, 0)
const totalLabel = "Total visits"

export default function ChartDonut() {
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-2">
      <span className="text-heading-sm">Traffic by source</span>
      <ChartContainer config={config} className="aspect-square h-72 w-full">
        <PieChart>
          <ChartTooltip
            content={<ChartTooltipContent nameKey="source" hideLabel />}
          />
          <Pie
            data={data}
            dataKey="visits"
            nameKey="source"
            innerRadius="58%"
            outerRadius="82%"
            stroke="var(--background)"
            strokeWidth={2}
          >
            <Label
              content={({ viewBox }) => {
                if (!viewBox || !("cx" in viewBox)) return null
                return (
                  <text
                    x={viewBox.cx}
                    y={viewBox.cy}
                    textAnchor="middle"
                    dominantBaseline="middle"
                  >
                    <tspan
                      x={viewBox.cx}
                      y={viewBox.cy}
                      className="fill-foreground text-2xl font-semibold"
                    >
                      {total.toLocaleString("en-US")}
                    </tspan>
                    <tspan
                      x={viewBox.cx}
                      y={(viewBox.cy ?? 0) + 22}
                      className="fill-muted-foreground text-xs"
                    >
                      {totalLabel}
                    </tspan>
                  </text>
                )
              }}
            />
          </Pie>
          <ChartLegend
            content={
              <ChartLegendContent nameKey="source" className="flex-wrap" />
            }
          />
        </PieChart>
      </ChartContainer>
    </div>
  )
}

Stat with sparkline

Often the right chart is a number. A stat tile leads with the value, states the change against a named period, and adds a 12-point sparkline: history in a quiet gray, the current period in the accent. Color the change by whether it's good news, and pair it with an arrow so direction never depends on color.

Recurring revenue$84,320+8.2%vs. August
p95 latency182 ms−24 msvs. August
Error rate0.42%+0.09 ptvs. August
"use client"

import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react"
import { Line, LineChart, YAxis } from "recharts"

import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
import { cn } from "@/lib/utils"

type Stat = {
  label: string
  value: string
  delta: string
  direction: "up" | "down"
  /** Whether the direction is good news — drives the delta color. */
  good: boolean
  trend: number[]
}

const stats: Stat[] = [
  {
    label: "Recurring revenue",
    value: "$84,320",
    delta: "+8.2%",
    direction: "up",
    good: true,
    trend: [61, 63, 62, 66, 68, 67, 71, 73, 72, 77, 80, 84],
  },
  {
    label: "p95 latency",
    value: "182 ms",
    delta: "−24 ms",
    direction: "down",
    good: true,
    trend: [240, 236, 228, 231, 219, 214, 209, 206, 198, 195, 190, 182],
  },
  {
    label: "Error rate",
    value: "0.42%",
    delta: "+0.09 pt",
    direction: "up",
    good: false,
    trend: [
      0.3, 0.31, 0.29, 0.33, 0.32, 0.35, 0.34, 0.36, 0.33, 0.38, 0.4, 0.42,
    ],
  },
]

const config = {
  value: { label: "Value", color: "var(--gray-8)" },
} satisfies ChartConfig

function Sparkline({ trend }: { trend: number[] }) {
  const data = trend.map((value, index) => ({ index, value }))
  const last = data.length - 1
  return (
    <ChartContainer
      config={config}
      className="aspect-auto h-10 w-full"
      aria-hidden="true"
    >
      {/* Decorative: hidden from assistive technology, so it mustn't take focus either. */}
      <LineChart
        data={data}
        margin={{ top: 5, right: 5, bottom: 5, left: 5 }}
        accessibilityLayer={false}
      >
        {/* A sparkline shows shape, not magnitude: fit the range, not zero. */}
        <YAxis hide domain={["dataMin", "dataMax"]} />
        <Line
          dataKey="value"
          type="monotone"
          stroke="var(--color-value)"
          strokeWidth={2}
          strokeLinecap="round"
          isAnimationActive={false}
          dot={(props: { cx?: number; cy?: number; index?: number }) =>
            props.index === last ? (
              <circle
                key="current"
                cx={props.cx}
                cy={props.cy}
                r={4}
                fill="var(--chart-1)"
                stroke="var(--card)"
                strokeWidth={2}
              />
            ) : (
              <g key={props.index} />
            )
          }
        />
      </LineChart>
    </ChartContainer>
  )
}

export default function ChartStat() {
  return (
    <div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
      {stats.map((stat) => {
        const Icon =
          stat.direction === "up" ? ArrowUpRightIcon : ArrowDownRightIcon
        return (
          <div
            key={stat.label}
            className="flex flex-col gap-2 rounded-xl border bg-card p-4 shadow-raised"
          >
            <span className="truncate text-body-xs text-muted-foreground">
              {stat.label}
            </span>
            {/* Proportional figures: tabular digits look loose at display size. */}
            <span className="text-heading-lg whitespace-nowrap">
              {stat.value}
            </span>
            <span
              className={cn(
                "flex items-center gap-1 text-xs font-medium whitespace-nowrap",
                stat.good ? "text-success-11" : "text-danger-11"
              )}
            >
              <Icon className="size-3.5" aria-hidden="true" />
              {stat.delta}
              <span className="font-normal text-muted-foreground">
                vs. August
              </span>
            </span>
            <Sparkline trend={stat.trend} />
          </div>
        )
      })}
    </div>
  )
}

Emphasis

When the point is one series, say so: show it in the accent and everything else in gray. This is the most underused chart form, and often the honest answer to "make this chart clearer".

Sydney waits 5.6× longer than FrankfurtMedian latency from Kraków, in milliseconds
"use client"

import { Bar, BarChart, Cell, LabelList, XAxis } from "recharts"

import { ChartContainer, type ChartConfig } from "@/components/ui/chart"

const data = [
  { region: "Frankfurt", ms: 38 },
  { region: "London", ms: 41 },
  { region: "Virginia", ms: 92 },
  { region: "São Paulo", ms: 141 },
  { region: "Singapore", ms: 188 },
  { region: "Sydney", ms: 214 },
]

const highlight = "Sydney"

const config = {
  ms: { label: "Median latency (ms)", color: "var(--gray-8)" },
} satisfies ChartConfig

export default function ChartEmphasis() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">
          Sydney waits 5.6× longer than Frankfurt
        </span>
        <span className="text-body-xs text-muted-foreground">
          Median latency from Kraków, in milliseconds
        </span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-64 w-full">
        <BarChart data={data} margin={{ top: 24, left: 8, right: 8 }}>
          <XAxis
            dataKey="region"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
          />
          <Bar
            dataKey="ms"
            radius={[4, 4, 0, 0]}
            maxBarSize={24}
            isAnimationActive={false}
          >
            {data.map((item) => (
              <Cell
                key={item.region}
                fill={
                  item.region === highlight
                    ? "var(--chart-1)"
                    : "var(--color-ms)"
                }
              />
            ))}
            <LabelList
              dataKey="ms"
              position="top"
              offset={8}
              className="fill-muted-foreground text-xs"
            />
          </Bar>
        </BarChart>
      </ChartContainer>
    </div>
  )
}

With a table view

Every chart should have a table twin. It's the most accessible form of the data, the one people copy from, and it keeps values reachable without hovering.

Support tickets per quarter
"use client"

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

const data = [
  { quarter: "Q4 2025", tickets: 1284 },
  { quarter: "Q1 2026", tickets: 1172 },
  { quarter: "Q2 2026", tickets: 968 },
  { quarter: "Q3 2026", tickets: 811 },
]

const config = {
  tickets: { label: "Support tickets", color: "var(--chart-1)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("en-US")

export default function ChartWithTable() {
  return (
    <Tabs defaultValue="chart" className="w-full max-w-xl">
      <div className="flex items-center justify-between gap-4">
        <span className="text-heading-sm">Support tickets per quarter</span>
        <TabsList>
          <TabsTrigger value="chart">Chart</TabsTrigger>
          <TabsTrigger value="table">Table</TabsTrigger>
        </TabsList>
      </div>
      <TabsContent value="chart">
        <ChartContainer config={config} className="aspect-auto h-56 w-full">
          <BarChart data={data} margin={{ left: 0, right: 8, top: 8 }}>
            <CartesianGrid vertical={false} />
            <XAxis
              dataKey="quarter"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
            />
            <YAxis
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              width={52}
              tickFormatter={(value: number) => number.format(value)}
            />
            <ChartTooltip content={<ChartTooltipContent hideIndicator />} />
            <Bar
              dataKey="tickets"
              fill="var(--color-tickets)"
              radius={[4, 4, 0, 0]}
              maxBarSize={24}
            />
          </BarChart>
        </ChartContainer>
      </TabsContent>
      <TabsContent value="table">
        <div className="rounded-xl border">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead scope="col">Quarter</TableHead>
                <TableHead scope="col" className="text-right">
                  Tickets
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {data.map((row) => (
                <TableRow key={row.quarter}>
                  <TableCell>{row.quarter}</TableCell>
                  <TableCell className="text-right">
                    {number.format(row.tickets)}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </TabsContent>
    </Tabs>
  )
}

Color

Color comes last: pick the form first, then give each color exactly one job.

JobEncodesprfct colors
CategoricalWhich serieschart-1 … chart-5, always assigned in order
SequentialHow muchOne hue, light → dark: brand steps 4 → 11
DivergingAbove or below a baselineBlue and red scales with a gray midpoint
StatusGood or badsuccess, warning, destructive — only when the series means good or bad
EmphasisThe one that matterschart-1 for the subject, gray-8 for context

Categorical palette

chart-1
chart-2
chart-3
chart-4
chart-5

The order — brand, teal, violet, orange, pink — isn't aesthetic. It was chosen by enumerating every ordering and keeping the one with the largest color difference between neighbors under simulated protanopia and deuteranopia. The four hues sit in the legible lightness band and clear the chroma floor; the first series is the brand color, black or white by default, and stands apart from them by lightness. Every series keeps at least 3:1 contrast with the chart surface, in both modes. tests/tokens.test.ts recomputes these figures from the tokens:

CheckLightDark
Worst adjacent pair, color-blind (ΔE OKLab)17.616.0
Worst adjacent pair, normal vision (ΔE OKLab)18.716.9
Contrast against the surface≥ 3:1≥ 3:1

Rules that follow from this:

  • Assign in order, never skip, never cycle. The first series is always chart-1. A sixth series folds into Other, becomes a small multiple, or moves to a table — never a generated sixth hue.
  • Color follows the entity, not its rank. When a filter removes a series, the others keep their colors.
  • Scatter plots and maps get three series at most — there, any two colors can end up side by side.
  • Status colors are reserved. Green and red mean good and bad everywhere in prfct; never use them for "series 4".

Guidelines

Choose the form first

The data's jobUse
One current value, maybe a trendA stat tile — not a one-bar chart
Change over timeLine; area for a single series
Compare magnitudesBar or column
Part-to-wholeStacked bar; a donut for up to six parts
One series matters, the rest is contextEmphasis
More than seven categories that all matterA table

Quiet chrome

Data is the only thing allowed to be loud. Gridlines are solid hairlines one step off the surface — never dashed. Axis labels use muted-foreground. Remove axis lines and tick marks; keep ticks at clean, comma-separated numbers.

DoHairline grid, muted labels, thin marks. The data carries the ink.
Don’tA different color per bar spends the identity channel on nothing — the length already shows the value.

One axis

Never plot two measures on two y-axes: the alignment of the scales is arbitrary, so the chart invents correlations. Use two charts, small multiples, or index both series to 100 at the start.

Labels and legends

  • A legend is always present for two or more series; a single series is named by the title.
  • Label selectively — the latest value, the extreme, the one series the story is about. On lines and dense charts, never put a number on every point; a handful of bars without a value axis can each carry their value, as in Emphasis.
  • Text wears text tokens. Values, labels and legends stay in foreground or muted-foreground; the colored mark beside them carries identity.

Accessibility

  • Keyboard. Recharts' accessibility layer is on by default: focus a chart with Tab, which draws a focus ring around it, and move between data points with the arrow keys; the tooltip follows focus exactly as it follows the pointer.
  • A text alternative. Pair every chart with a table view, or describe the takeaway in its title and caption — "Signups grew 64% since April" says more than "Signups by month".
  • Never color alone. Legends and direct labels identify series; status changes pair color with an icon and words.
  • Contrast. Every categorical color keeps at least 3:1 against the chart surface in both modes.
  • Motion. Recharts animations default to "auto", which respects prefers-reduced-motion. Keep it.
KeyBehavior
Tab
Moves focus to the chart.
←→
Moves between data points and updates the tooltip.

API reference

ChartContainer

Wraps a Recharts chart, scopes its colors and makes it responsive. Accepts all div props.

PropTypeDefault
config

Labels, icons and colors for each series key. Every color becomes --color-key inside the chart.

ChartConfigNo default
initialDimension

Size used before the container is measured, for example during server rendering.

{ width: number; height: number }{ width: 320, height: 200 }
className

Set a height here, e.g. aspect-auto h-64. The default is a 16:9 aspect ratio.

stringNo default

ChartConfig

type ChartConfig = Record<
  string,
  {
    label?: React.ReactNode
    icon?: React.ComponentType
  } & (
    | { color?: string } // one color for both modes
    | { theme: { light: string; dark: string } } // a color per mode
  )
>

ChartTooltipContent

Use as <ChartTooltip content={<ChartTooltipContent />} />.

PropTypeDefault
indicator

Series key style. Prefer line for line and area charts.

"dot" | "line" | "dashed""dot"
hideLabel

Hides the category label at the top of the tooltip.

booleanfalse
hideIndicator

Hides the series key, e.g. for single-series charts.

booleanfalse
nameKey

Payload key used to look up the series label in config.

stringNo default
labelKey

Payload key used for the tooltip label.

stringNo default
formatter

Replaces the default row rendering.

(value, name, item, index, payload) => ReactNodeNo default
labelFormatter

Formats the label at the top of the tooltip.

(label, payload) => ReactNodeNo default
labelClassName

Classes for that label.

stringNo default
color

One color for every series key, in place of each series' own.

stringNo default

ChartLegendContent

Use as <ChartLegend content={<ChartLegendContent />} />. ChartLegend lists the series in the order of the keys in config, the same order the colors are assigned in, where Recharts alone would sort them alphabetically. Pass itemSorter to choose another order.

PropTypeDefault
nameKey

Payload key used to look up labels, e.g. for pie slices.

stringNo default
hideIcon

Hides config icons and shows color keys instead.

booleanfalse
verticalAlign

Adds spacing on the side facing the chart.

"top" | "bottom""bottom"

ChartStyle

Injected by ChartContainer; exported for custom containers that need the same scoped color variables.