Combo Chart
Combined bar and line chart card.
Combo
Bars and a line on shared axes.
Sessions · Conversion 3.7%
83,200
+9.4%function ComboChartDemo() {
return (
<ComboChartCard
data={COMBO_DATA}
bar={COMBO_BAR}
line={COMBO_LINE}
ranges={COMBO_RANGES}
/>
);
}function ComboChartDemo() {
return (
<ComboChartCard
data={COMBO_DATA}
bar={COMBO_BAR}
line={COMBO_LINE}
ranges={COMBO_RANGES}
/>
);
}Installation
npx shadcn@latest add @boardcn/combo-chart-cardnpx shadcn@latest add @boardcn/combo-chart-cardnpm packages
- recharts
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import { useState, type ReactNode } from "react";
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Chip, type ChipProps } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type ComboChartDatum = {
label: string;
[key: string]: string | number;
};
export interface ComboChartSeries {
key: string;
label: string;
color?: string;
activeColor?: string;
format?: (value: number) => string;
}
export interface ComboChartRange {
id: string;
label: string;
data: ComboChartDatum[];
headline?: number;
delta?: number;
}
export interface ComboChartCardProps {
title?: string;
data: ComboChartDatum[];
bar: ComboChartSeries;
line: ComboChartSeries;
headline?: number;
delta?: number;
/** A noninteractive period pill. */
range?: string;
/** Periods shown in the top-right dropdown. */
ranges?: ComboChartRange[];
defaultRange?: string;
onRangeChange?: (id: string) => void;
/** Adds the total and average tiles and gives the plot the preview's fixed height. */
tiles?: boolean;
className?: string;
}
type Tone = { color: string; activeColor: string };
const DEFAULT_TONES: Tone[] = [2, 6, 5, 3, 8, 7, 4, 1].map((tone) => ({
color: `var(--color-chart-${tone})`,
activeColor: `var(--color-chart-${tone}-active)`,
}));
const formatNumber = (value: number) => value.toLocaleString("en-US");
const formatCompact = (value: number) =>
value >= 1000 ? `${Math.round(value / 100) / 10}K`.replace(".0K", "K") : String(Math.round(value));
const formatPercent = (value: number) => `${Math.round(value * 10) / 10}%`;
function useAnimatedNumber(value: number) {
const precision = Number.isInteger(value)
? 1
: Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
? 10
: 100;
return useCountUp(Math.round(value * precision)) / precision;
}
function resolveTone(index: number, color?: string, activeColor?: string): Tone {
if (color) {
return {
color,
activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
return DEFAULT_TONES[index % DEFAULT_TONES.length];
}
function describeDelta(delta: number): { label: string; color: ChipProps["color"] } {
const percent = Math.round(Math.abs(delta) * 1000) / 10;
if (percent === 0) return { label: "0.0%", color: "neutral" };
return {
label: `${delta > 0 ? "+" : "-"}${percent}%`,
color: delta > 0 ? "lime" : "rose",
};
}
function ChartCard({ className, children }: { className?: string; children: ReactNode }) {
return (
<section
className={cx(
"flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
className,
)}
>
{children}
</section>
);
}
function ChartHeader({
label,
value,
format,
delta,
hovering,
fadeKey,
range,
ranges,
rangeId,
onRangeChange,
}: {
label: string;
value: number;
format: (value: number) => string;
delta?: ReturnType<typeof describeDelta>;
hovering: boolean;
fadeKey: string;
range?: string;
ranges?: ComboChartRange[];
rangeId?: string;
onRangeChange: (id: string) => void;
}) {
const animatedValue = useAnimatedNumber(value);
return (
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="w-full truncate text-body-medium text-text-secondary">{label}</p>
<div className="flex w-full items-center gap-2">
<p
key={fadeKey}
className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
>
{format(animatedValue)}
</p>
{delta && (
<Chip variant="bold" color={delta.color} className={hovering ? "invisible" : undefined}>
{delta.label}
</Chip>
)}
</div>
</div>
{ranges && ranges.length > 0 ? (
<ChartRangeControl ranges={ranges} value={rangeId} onValueChange={onRangeChange} />
) : range ? (
<ChartRangeControl label={range} />
) : null}
</div>
);
}
function ChartStatTiles({
items,
}: {
items: Array<{ label: string; value: string; color: string; activeColor: string }>;
}) {
return (
<div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
{items.map((item, index) => (
<div
key={`${item.label}-${index}`}
className="col-span-1 flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 sm:col-span-3"
>
<div className="flex min-w-0 max-w-full items-center gap-1.5">
<span
className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out"
style={{ backgroundColor: item.color }}
/>
<span className="truncate text-body-regular text-text-secondary">{item.label}</span>
</div>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{item.value}</span>
</div>
))}
</div>
);
}
function PulseDot({ cx: dotX, cy: dotY, color }: { cx?: number; cy?: number; color: string }) {
if (dotX == null || dotY == null) return null;
return (
<g>
<circle cx={dotX} cy={dotY} r={5} fill={color} opacity={0.3}>
<animate attributeName="r" values="5;13" dur="1.4s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.35;0" dur="1.4s" repeatCount="indefinite" />
</circle>
<circle
cx={dotX}
cy={dotY}
r={5}
fill={color}
stroke="var(--color-background-secondary-default)"
strokeWidth={3}
/>
</g>
);
}
const BAR_RADIUS: [number, number, number, number] = [8, 8, 8, 8];
export function ComboChartCard({
title = "Sessions",
data,
bar,
line,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
tiles = false,
className,
}: ComboChartCardProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const availableRanges = ranges;
const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
const selectedRange =
availableRanges?.find((item) => item.id === selectedRangeId) ?? availableRanges?.[0];
const chartData = selectedRange?.data ?? data;
const barSeries = bar;
const lineSeries = line;
const rangeHeadline = selectedRange?.headline ?? headline;
const rangeDelta = selectedRange?.delta ?? delta;
const barTone = resolveTone(0, barSeries.color, barSeries.activeColor);
const lineTone = resolveTone(1, lineSeries.color, lineSeries.activeColor);
const formatBar = barSeries.format ?? formatNumber;
const formatLine = lineSeries.format ?? formatPercent;
const valueAt = (key: string, index: number) => Number(chartData[index]?.[key] ?? 0);
const barTotal = chartData.reduce((sum, row) => sum + Number(row[barSeries.key] ?? 0), 0);
const lineAverage = chartData.length
? chartData.reduce((sum, row) => sum + Number(row[lineSeries.key] ?? 0), 0) / chartData.length
: 0;
const isHovering = activeIndex !== null && activeIndex < chartData.length;
const currentLabel = isHovering ? String(chartData[activeIndex].label) : title;
const currentHeadline = isHovering
? valueAt(barSeries.key, activeIndex)
: (rangeHeadline ?? barTotal);
const currentLineValue = isHovering ? valueAt(lineSeries.key, activeIndex) : lineAverage;
const selectRange = (id: string) => {
setActiveIndex(null);
setSelectedRangeId(id);
onRangeChange?.(id);
};
return (
<ChartCard className={cx(tiles && "h-auto", className)}>
<ChartHeader
label={`${currentLabel} · ${lineSeries.label} ${formatLine(currentLineValue)}`}
value={currentHeadline}
format={formatBar}
delta={rangeDelta === undefined ? undefined : describeDelta(rangeDelta)}
hovering={isHovering}
fadeKey={`${selectedRange?.id ?? ""}:${activeIndex}`}
range={range}
ranges={availableRanges}
rangeId={selectedRange?.id}
onRangeChange={selectRange}
/>
<div className={cx("min-h-0 w-full flex-1", tiles && "h-[196px] flex-none")}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
barCategoryGap="22%"
onMouseMove={(state) => {
const index = Number(state?.activeTooltipIndex);
setActiveIndex(state?.isTooltipActive && Number.isInteger(index) ? index : null);
}}
onMouseLeave={() => setActiveIndex(null)}
>
<CartesianGrid
vertical={false}
stroke="var(--color-chart-track)"
strokeDasharray="4 4"
/>
<YAxis
yAxisId="bars"
width={44}
tickCount={4}
tickFormatter={formatCompact}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
/>
<YAxis
yAxisId="line"
orientation="right"
width={40}
tickCount={4}
tickFormatter={formatLine}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={12}
interval="preserveStartEnd"
tick={{ fontSize: 13, fill: "var(--color-text-tertiary)" }}
/>
<Tooltip content={() => null} cursor={false} isAnimationActive={false} />
<Bar
yAxisId="bars"
dataKey={barSeries.key}
name={barSeries.label}
radius={BAR_RADIUS}
maxBarSize={34}
activeBar={false}
isAnimationActive
animationDuration={450}
>
{chartData.map((row, index) => (
<Cell
key={String(row.label)}
fill={index === activeIndex ? barTone.activeColor : barTone.color}
opacity={isHovering && index !== activeIndex ? 0.3 : 1}
className="transition-[fill,opacity] duration-200 ease-out"
/>
))}
</Bar>
<Line
yAxisId="line"
type="monotone"
dataKey={lineSeries.key}
stroke="var(--color-background-secondary-default)"
strokeWidth={7}
strokeLinecap="round"
strokeLinejoin="round"
dot={false}
activeDot={false}
legendType="none"
isAnimationActive
animationDuration={450}
/>
<Line
yAxisId="line"
type="monotone"
dataKey={lineSeries.key}
name={lineSeries.label}
stroke={lineTone.activeColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
dot={{
r: 3,
fill: lineTone.activeColor,
stroke: "var(--color-background-secondary-default)",
strokeWidth: 2,
}}
activeDot={<PulseDot color={lineTone.activeColor} />}
isAnimationActive
animationDuration={450}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{tiles && (
<ChartStatTiles
items={[
{
label: `${barSeries.label} · total`,
value: formatBar(isHovering ? valueAt(barSeries.key, activeIndex) : barTotal),
color: barTone.color,
activeColor: barTone.activeColor,
},
{
label: `${lineSeries.label} · ${isHovering ? "this month" : "average"}`,
value: formatLine(currentLineValue),
color: lineTone.color,
activeColor: lineTone.activeColor,
},
]}
/>
)}
</ChartCard>
);
}"use client";
import { useState, type ReactNode } from "react";
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Chip, type ChipProps } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type ComboChartDatum = {
label: string;
[key: string]: string | number;
};
export interface ComboChartSeries {
key: string;
label: string;
color?: string;
activeColor?: string;
format?: (value: number) => string;
}
export interface ComboChartRange {
id: string;
label: string;
data: ComboChartDatum[];
headline?: number;
delta?: number;
}
export interface ComboChartCardProps {
title?: string;
data: ComboChartDatum[];
bar: ComboChartSeries;
line: ComboChartSeries;
headline?: number;
delta?: number;
/** A noninteractive period pill. */
range?: string;
/** Periods shown in the top-right dropdown. */
ranges?: ComboChartRange[];
defaultRange?: string;
onRangeChange?: (id: string) => void;
/** Adds the total and average tiles and gives the plot the preview's fixed height. */
tiles?: boolean;
className?: string;
}
type Tone = { color: string; activeColor: string };
const DEFAULT_TONES: Tone[] = [2, 6, 5, 3, 8, 7, 4, 1].map((tone) => ({
color: `var(--color-chart-${tone})`,
activeColor: `var(--color-chart-${tone}-active)`,
}));
const formatNumber = (value: number) => value.toLocaleString("en-US");
const formatCompact = (value: number) =>
value >= 1000 ? `${Math.round(value / 100) / 10}K`.replace(".0K", "K") : String(Math.round(value));
const formatPercent = (value: number) => `${Math.round(value * 10) / 10}%`;
function useAnimatedNumber(value: number) {
const precision = Number.isInteger(value)
? 1
: Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
? 10
: 100;
return useCountUp(Math.round(value * precision)) / precision;
}
function resolveTone(index: number, color?: string, activeColor?: string): Tone {
if (color) {
return {
color,
activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
return DEFAULT_TONES[index % DEFAULT_TONES.length];
}
function describeDelta(delta: number): { label: string; color: ChipProps["color"] } {
const percent = Math.round(Math.abs(delta) * 1000) / 10;
if (percent === 0) return { label: "0.0%", color: "neutral" };
return {
label: `${delta > 0 ? "+" : "-"}${percent}%`,
color: delta > 0 ? "lime" : "rose",
};
}
function ChartCard({ className, children }: { className?: string; children: ReactNode }) {
return (
<section
className={cx(
"flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
className,
)}
>
{children}
</section>
);
}
function ChartHeader({
label,
value,
format,
delta,
hovering,
fadeKey,
range,
ranges,
rangeId,
onRangeChange,
}: {
label: string;
value: number;
format: (value: number) => string;
delta?: ReturnType<typeof describeDelta>;
hovering: boolean;
fadeKey: string;
range?: string;
ranges?: ComboChartRange[];
rangeId?: string;
onRangeChange: (id: string) => void;
}) {
const animatedValue = useAnimatedNumber(value);
return (
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="w-full truncate text-body-medium text-text-secondary">{label}</p>
<div className="flex w-full items-center gap-2">
<p
key={fadeKey}
className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
>
{format(animatedValue)}
</p>
{delta && (
<Chip variant="bold" color={delta.color} className={hovering ? "invisible" : undefined}>
{delta.label}
</Chip>
)}
</div>
</div>
{ranges && ranges.length > 0 ? (
<ChartRangeControl ranges={ranges} value={rangeId} onValueChange={onRangeChange} />
) : range ? (
<ChartRangeControl label={range} />
) : null}
</div>
);
}
function ChartStatTiles({
items,
}: {
items: Array<{ label: string; value: string; color: string; activeColor: string }>;
}) {
return (
<div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
{items.map((item, index) => (
<div
key={`${item.label}-${index}`}
className="col-span-1 flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 sm:col-span-3"
>
<div className="flex min-w-0 max-w-full items-center gap-1.5">
<span
className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out"
style={{ backgroundColor: item.color }}
/>
<span className="truncate text-body-regular text-text-secondary">{item.label}</span>
</div>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{item.value}</span>
</div>
))}
</div>
);
}
function PulseDot({ cx: dotX, cy: dotY, color }: { cx?: number; cy?: number; color: string }) {
if (dotX == null || dotY == null) return null;
return (
<g>
<circle cx={dotX} cy={dotY} r={5} fill={color} opacity={0.3}>
<animate attributeName="r" values="5;13" dur="1.4s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.35;0" dur="1.4s" repeatCount="indefinite" />
</circle>
<circle
cx={dotX}
cy={dotY}
r={5}
fill={color}
stroke="var(--color-background-secondary-default)"
strokeWidth={3}
/>
</g>
);
}
const BAR_RADIUS: [number, number, number, number] = [8, 8, 8, 8];
export function ComboChartCard({
title = "Sessions",
data,
bar,
line,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
tiles = false,
className,
}: ComboChartCardProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const availableRanges = ranges;
const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
const selectedRange =
availableRanges?.find((item) => item.id === selectedRangeId) ?? availableRanges?.[0];
const chartData = selectedRange?.data ?? data;
const barSeries = bar;
const lineSeries = line;
const rangeHeadline = selectedRange?.headline ?? headline;
const rangeDelta = selectedRange?.delta ?? delta;
const barTone = resolveTone(0, barSeries.color, barSeries.activeColor);
const lineTone = resolveTone(1, lineSeries.color, lineSeries.activeColor);
const formatBar = barSeries.format ?? formatNumber;
const formatLine = lineSeries.format ?? formatPercent;
const valueAt = (key: string, index: number) => Number(chartData[index]?.[key] ?? 0);
const barTotal = chartData.reduce((sum, row) => sum + Number(row[barSeries.key] ?? 0), 0);
const lineAverage = chartData.length
? chartData.reduce((sum, row) => sum + Number(row[lineSeries.key] ?? 0), 0) / chartData.length
: 0;
const isHovering = activeIndex !== null && activeIndex < chartData.length;
const currentLabel = isHovering ? String(chartData[activeIndex].label) : title;
const currentHeadline = isHovering
? valueAt(barSeries.key, activeIndex)
: (rangeHeadline ?? barTotal);
const currentLineValue = isHovering ? valueAt(lineSeries.key, activeIndex) : lineAverage;
const selectRange = (id: string) => {
setActiveIndex(null);
setSelectedRangeId(id);
onRangeChange?.(id);
};
return (
<ChartCard className={cx(tiles && "h-auto", className)}>
<ChartHeader
label={`${currentLabel} · ${lineSeries.label} ${formatLine(currentLineValue)}`}
value={currentHeadline}
format={formatBar}
delta={rangeDelta === undefined ? undefined : describeDelta(rangeDelta)}
hovering={isHovering}
fadeKey={`${selectedRange?.id ?? ""}:${activeIndex}`}
range={range}
ranges={availableRanges}
rangeId={selectedRange?.id}
onRangeChange={selectRange}
/>
<div className={cx("min-h-0 w-full flex-1", tiles && "h-[196px] flex-none")}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
barCategoryGap="22%"
onMouseMove={(state) => {
const index = Number(state?.activeTooltipIndex);
setActiveIndex(state?.isTooltipActive && Number.isInteger(index) ? index : null);
}}
onMouseLeave={() => setActiveIndex(null)}
>
<CartesianGrid
vertical={false}
stroke="var(--color-chart-track)"
strokeDasharray="4 4"
/>
<YAxis
yAxisId="bars"
width={44}
tickCount={4}
tickFormatter={formatCompact}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
/>
<YAxis
yAxisId="line"
orientation="right"
width={40}
tickCount={4}
tickFormatter={formatLine}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={12}
interval="preserveStartEnd"
tick={{ fontSize: 13, fill: "var(--color-text-tertiary)" }}
/>
<Tooltip content={() => null} cursor={false} isAnimationActive={false} />
<Bar
yAxisId="bars"
dataKey={barSeries.key}
name={barSeries.label}
radius={BAR_RADIUS}
maxBarSize={34}
activeBar={false}
isAnimationActive
animationDuration={450}
>
{chartData.map((row, index) => (
<Cell
key={String(row.label)}
fill={index === activeIndex ? barTone.activeColor : barTone.color}
opacity={isHovering && index !== activeIndex ? 0.3 : 1}
className="transition-[fill,opacity] duration-200 ease-out"
/>
))}
</Bar>
<Line
yAxisId="line"
type="monotone"
dataKey={lineSeries.key}
stroke="var(--color-background-secondary-default)"
strokeWidth={7}
strokeLinecap="round"
strokeLinejoin="round"
dot={false}
activeDot={false}
legendType="none"
isAnimationActive
animationDuration={450}
/>
<Line
yAxisId="line"
type="monotone"
dataKey={lineSeries.key}
name={lineSeries.label}
stroke={lineTone.activeColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
dot={{
r: 3,
fill: lineTone.activeColor,
stroke: "var(--color-background-secondary-default)",
strokeWidth: 2,
}}
activeDot={<PulseDot color={lineTone.activeColor} />}
isAnimationActive
animationDuration={450}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{tiles && (
<ChartStatTiles
items={[
{
label: `${barSeries.label} · total`,
value: formatBar(isHovering ? valueAt(barSeries.key, activeIndex) : barTotal),
color: barTone.color,
activeColor: barTone.activeColor,
},
{
label: `${lineSeries.label} · ${isHovering ? "this month" : "average"}`,
value: formatLine(currentLineValue),
color: lineTone.color,
activeColor: lineTone.activeColor,
},
]}
/>
)}
</ChartCard>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
ComboChartCard
| Prop | Type | Default | Description |
|---|---|---|---|
| barrequired | ComboChartSeries | — | — |
| datarequired | ComboChartDatum[] | — | — |
| linerequired | ComboChartSeries | — | — |
| className | string | — | — |
| defaultRange | string | — | — |
| delta | number | — | — |
| headline | number | — | — |
| onRangeChange | (id: string) => void | — | — |
| range | string | — | A noninteractive period pill. |
| ranges | ComboChartRange[] | — | Periods shown in the top-right dropdown. |
| tiles | boolean | false | Adds the total and average tiles and gives the plot the preview's fixed height. |
| title | string | Sessions | — |