Skip to main content

shadcn/ui charts

shadcn/ui's charts are Recharts charts inside a component you copy into your app, chart.tsx (npx shadcn add chart): <ChartContainer config> around the chart, and <ChartLegendContent> and <ChartTooltipContent> for its legend and tooltip. The config (shadcn's ChartConfig) names each series: its label, its colour (one, or one per theme) and an optional icon. teamChartConfig(league, teams) from @sportsdataverse/sdvplot/shadcn builds that config for teams: each team's name as its label, its colours for shadcn's light and dark themes, and its logo as its icon, so the legend and tooltip show logos where shadcn draws a colour swatch. Draw the series themselves with Recharts and sdvplot/recharts (logoTick for logos on an axis, logoDot for logos as points). shadcn is not a package, so there is nothing to install beside sdvplot but recharts (an optional peer, 3.8 or later; these examples run recharts 3.10.1 and shadcn's chart.tsx as of 2026-10-26): the subpath imports neither, and its types only match shadcn's shape. It is synchronous, so load the league first (loadLeague). Each chart below is drawn by Recharts in your browser, through shadcn's own chart.tsx.

Keys, and the colour variables​

<ChartContainer> writes each config key into CSS as a custom property, --color-<key>, and a series takes its colour from it: stroke or fill set to var(--color-<key>). A team name such as "Trail Blazers" (a space) or an id such as "1610612757.0" (a dot) is not a valid property name, so teamChartConfig returns the keys with the config, { config, keys }, where keys[i] is the key of teams[i]. A key is the team value as given, with its accents dropped ("Montréal" gives "montreal"), lower-cased, and each run of anything other than letters and digits turned into one dash, with the dashes at either end removed: "Trail Blazers" gives trail-blazers. Letters of other scripts stay, as CSS allows them. A value with nothing left becomes team. A key already taken gets -2, then -3, and so on, in the order of teams, so the same teams in the same order always get the same keys. Pass each team once, and read its key from keys[i] rather than working it out again. Use keys[i] for that team's series dataKey, and its colour is var(--color-<keys[i]>).

The chart below has one line per team, with each team's key as the line's dataKey. The legend and the tooltip find each team's config entry by that dataKey, and draw its logo and name. Recharts' <Legend> (shadcn's <ChartLegend>) sorts its items by name unless told otherwise; pass itemSorter={null}, as this chart does, to keep the series order.

Kansas City Chiefs
Los Angeles Chargers
Denver Broncos
Las Vegas Raiders
1612180481216
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamChartConfig } from "@sportsdataverse/sdvplot/shadcn";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";

// shadcn's chart.tsx, as `npx shadcn add chart` copies it into an app
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "../../vendor/shadcn/chart.js";

await loadLeague("nfl");
const TEAMS = ["KC", "LAC", "DEN", "LV"];
const west = TEAMS.map((team) => {
const t = AFC_WEEKS_2024.find((w) => w.team === team);
if (t === undefined) throw new Error(`${team} is not in AFC_WEEKS_2024`);
return t;
});
// a CSS-safe key per team: each line's dataKey and its colour, var(--color-<key>); the legend draws each logo
const { config, keys } = teamChartConfig("nfl", TEAMS);
// one row per week, one column per team (its key): wins so far, so a bye week keeps the total
const rows = Array.from({ length: 18 }, (_, w) => ({
week: w + 1,
...Object.fromEntries(
west.map((t, i) => [keys[i], t.margins.slice(0, w + 1).filter((m) => m !== null && m > 0).length]),
),
}));

(
<ChartContainer
id="afc-west-wins"
config={config}
initialDimension={{ width: 560, height: 360 }}
style={{ width: 560, height: 360, background: "#ffffff" }}
>
<LineChart data={rows} margin={{ top: 12, right: 16, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="week" type="number" domain={[1, 18]} ticks={[1, 6, 12, 18]} tickLine={false} />
<YAxis domain={[0, 16]} tickLine={false} width={32} />
<ChartTooltip
content={<ChartTooltipContent labelFormatter={(_, payload) => `Week ${payload[0]?.payload.week}`} />}
/>
{/* itemSorter={null}: the legend in the lines' order (Recharts sorts it by name otherwise) */}
<ChartLegend content={<ChartLegendContent />} itemSorter={null} />
{keys.map((key) => (
<Line
key={key}
dataKey={key}
stroke={`var(--color-${key})`}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
);

Light and dark colours​

Each entry has a theme rather than a single color: theme.light is the team's colour on a light chart and theme.dark its colour on a dark one. They come from teamColorPalette in sdvplot/recharts, given each theme's background. By default the backgrounds are those of shadcn's neutral theme, the one shadcn init sets up: #ffffff (oklch(1 0 0), its page and its card) for light, and #171717 for dark (oklch(0.205 0 0), its dark card). A colour that reads on that card also reads on the darker page behind it, #0a0a0a. Every colour keeps 3:1 contrast against its background. A team colour that falls short (Denver's navy and the Raiders' black on the dark card) gives way to the team's other colour, or is lightened until it reads. Each colour also aims to stay distinct from the earlier teams' (CIE76 ΔE 20), as in the other adapters. For a different theme, pass its backgrounds as hex: background: { light, dark }. which, season and idSystem work as they do in teamColorPalette.

shadcn writes theme.light under the chart and theme.dark under any element with the dark class above it, its dark mode. This chart sits inside a class="dark" box on the dark card colour:

Kansas City Chiefs
Los Angeles Chargers
Denver Broncos
Las Vegas Raiders
161218-150-75075150
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamChartConfig } from "@sportsdataverse/sdvplot/shadcn";
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";

import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "../../vendor/shadcn/chart.js";

await loadLeague("nfl");
const TEAMS = ["KC", "LAC", "DEN", "LV"];
const west = TEAMS.map((team) => {
const t = AFC_WEEKS_2024.find((w) => w.team === team);
if (t === undefined) throw new Error(`${team} is not in AFC_WEEKS_2024`);
return t;
});
// each team's `theme.dark` colour reads on shadcn's dark card (3:1): Denver's navy and the Raiders' black are lifted
const { config, keys } = teamChartConfig("nfl", TEAMS);
const rows = Array.from({ length: 18 }, (_, w) => ({
week: w + 1,
...Object.fromEntries(
west.map((t, i) => [keys[i], t.margins.slice(0, w + 1).reduce<number>((sum, m) => sum + (m ?? 0), 0)]),
),
}));

// shadcn's dark mode is a `dark` class above the chart: its <ChartStyle> sets each --color-<key> to `theme.dark` there
(
<div className="dark" style={{ background: "#171717", padding: 8, width: 560 }}>
<ChartContainer
id="afc-west-margin-dark"
config={config}
initialDimension={{ width: 560, height: 360 }}
style={{ width: 560, height: 360 }}
>
<LineChart data={rows} margin={{ top: 12, right: 16, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} />
<ReferenceLine y={0} />
<XAxis dataKey="week" type="number" domain={[1, 18]} ticks={[1, 6, 12, 18]} tickLine={false} />
<YAxis domain={[-150, 150]} ticks={[-150, -75, 0, 75, 150]} tickLine={false} width={40} />
<ChartTooltip
content={
<ChartTooltipContent labelFormatter={(_, payload) => `Week ${payload[0]?.payload.week}`} />
}
/>
<ChartLegend content={<ChartLegendContent />} itemSorter={null} />
{keys.map((key) => (
<Line
key={key}
dataKey={key}
stroke={`var(--color-${key})`}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
</div>
);

Logos in the legend and the tooltip​

icon is a component that draws the team's logo as an <img>: 16 px tall by default, or iconSize. Its alt is empty, because shadcn puts the team's name beside it. <ChartLegendContent> and <ChartTooltipContent> draw it in place of their colour swatch. A team with no logo in the archive gets no icon, so shadcn draws its swatch, and the call warns once. icon: false leaves out every logo. The same logo at the same size is the same component on every call, so building the config inside a component's render is safe: React keeps the image rather than remounting it. Wrap the call in useMemo only to skip the palette work on each render.

A bar chart has one series, with one row per team, so the tooltip cannot find the team by dataKey. Give each row a column holding its team's key (key: keys[i]) and pass that column's name as <ChartTooltipContent nameKey="key">. The tooltip then shows the hovered bar's logo and team name. Each row's fill: var(--color-<key>) colours its bar, as in shadcn's own bar charts. logoTick from sdvplot/recharts puts the logos on the axis.

0150300450
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoTick } from "@sportsdataverse/sdvplot/recharts";
import { teamChartConfig } from "@sportsdataverse/sdvplot/shadcn";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";

import { ChartContainer, ChartTooltip, ChartTooltipContent } from "../../vendor/shadcn/chart.js";

await loadLeague("nfl");
const TEAMS = ["KC", "LAC", "DEN", "LV"];
const { config, keys } = teamChartConfig("nfl", TEAMS);
const rows = TEAMS.map((team, i) => {
const s = STANDINGS.find((t) => t.team === team);
if (s === undefined) throw new Error(`${team} is not in STANDINGS`);
// `key` finds the team's config (the tooltip's nameKey: its logo and name); `fill` paints the bar, as shadcn's do
return { team, key: keys[i], pf: s.pf, fill: `var(--color-${keys[i]})` };
});

(
<ChartContainer
id="afc-west-points"
config={config}
initialDimension={{ width: 520, height: 340 }}
style={{ width: 520, height: 340, background: "#ffffff" }}
>
<BarChart data={rows} margin={{ top: 16, right: 16, bottom: 8, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="team"
interval={0}
tickLine={false}
height={44}
tick={logoTick({ league: "nfl", size: 32 })}
/>
<YAxis domain={[0, 450]} tickLine={false} width={40} />
<ChartTooltip cursor={false} content={<ChartTooltipContent nameKey="key" hideLabel />} />
<Bar dataKey="pf" radius={6} isAnimationActive={false} />
</BarChart>
</ChartContainer>
);

Server rendering (Next.js)​

chart.tsx begins with "use client": shadcn's chart components are client components. teamChartConfig touches no DOM and runs in Node, but it is synchronous: the league must be loaded (await loadLeague(league)) before it is called, as for every sdvplot logo helper. The config cannot be passed from a server component to a client one, because its icons are functions. Build it in the client component, once the league has loaded: for example, an effect that awaits loadLeague and then sets state, or React's use() on a promise created once at module level, inside a <Suspense>. A client component still renders once on the server, and the icons render there as plain <img> tags.

On this site​

This site has no Tailwind. The examples use shadcn's chart.tsx as shadcn ships it (examples/src/vendor/shadcn, MIT; only its cn import changed). The site adds a small stylesheet for the few utility classes its legend and tooltip use, with shadcn's neutral theme colours. In an app that has Tailwind and shadcn's theme, none of that is needed. Without either, the chart still draws: its series take their colours from --color-<key>, which shadcn writes itself. Only the legend and tooltip lose their layout.

In the gallery: shadcn/ui.