- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Visitors · Sign-up rate 3.3%
50,000
+25.6%7.5K5K2.5K0
JanFebMarAprMayJunJulAugSepOctNovDec
6.0%4.0%2.0%0.0%
Visitors · total
50,000
Sign-up rate · average
3.3%
Visitors · Sign-up rate 3.3%
50,000
+25.6%7.5K5K2.5K0
JanFebMarAprMayJunJulAugSepOctNovDec
6.0%4.0%2.0%0.0%
Installation
Set up the @cobalt registry as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/combo-chart-card-01
Usage
Provide rows with a label and numeric fields for both series. Configure the field, label, optional color, and optional formatter separately for each series. ranges adds a period menu, and tiles displays the total and average below the chart. Pass a popupClassName when a scoped theme must also apply to the menu.
<ComboChartCard
title="Visitors"
data={data}
bar={{ key: "visits", label: "Visitors" }}
line={{
key: "signup",
label: "Sign-up rate",
format: (n) => `${n.toFixed(1)}%`,
}}
tiles
/>Source
"use client";
import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";
export interface ComboChartPoint {
label: string;
[key: string]: string | number;
}
export interface ComboChartSeries {
key: string;
label: string;
color?: string;
format?: (value: number) => string;
}
export interface ComboChartRange {
id: string;
label: string;
data: ComboChartPoint[];
delta?: number;
}
export interface ComboChartCardProps {
title: string;
data: ComboChartPoint[];
bar: ComboChartSeries;
line: ComboChartSeries;
delta?: number;
ranges?: ComboChartRange[];
tiles?: boolean;
className?: string;
popupClassName?: string;
}
const number = (value: number) =>
new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(value);
const valueOf = (point: ComboChartPoint, key: string) => {
const value = point[key];
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, value)
: 0;
};
function axisMax(value: number) {
if (value <= 0) {
return 1;
}
const step = value / 3;
const magnitude = 10 ** Math.floor(Math.log10(step));
return (
([1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= step) ?? 10) *
magnitude *
3
);
}
function compact(value: number) {
return value >= 1000 ? `${number(value / 1000)}K` : number(value);
}
/** A number that rolls to its value without re-rendering anything around it. */
function Count({
value,
format,
}: {
value: number;
format: (value: number) => string;
}) {
const ref = useCountUp<HTMLSpanElement>(value, { format, precision: 2 });
return <span ref={ref}>{format(value)}</span>;
}
export function ComboChartCard({
title,
data,
bar,
line,
delta,
ranges,
tiles = false,
className,
popupClassName,
}: ComboChartCardProps) {
const [rangeId, setRangeId] = React.useState(ranges?.[0]?.id);
const [active, setActive] = React.useState<number | null>(null);
const [focused, setFocused] = React.useState<number | null>(null);
const range = ranges?.find((item) => item.id === rangeId);
const rows = range?.data ?? data;
const change = range?.delta ?? delta;
const barFormat = bar.format ?? number;
const lineFormat = line.format ?? number;
const total = rows.reduce((sum, row) => sum + valueOf(row, bar.key), 0);
const average = rows.length
? rows.reduce((sum, row) => sum + valueOf(row, line.key), 0) / rows.length
: 0;
const selected = active === null ? undefined : rows[active];
const shownBar = selected ? valueOf(selected, bar.key) : total;
const shownLine = selected ? valueOf(selected, line.key) : average;
const barMax = axisMax(
Math.max(0, ...rows.map((row) => valueOf(row, bar.key))) * 1.05
);
const lineMax = axisMax(
Math.max(0, ...rows.map((row) => valueOf(row, line.key))) * 1.05
);
const barColor = bar.color ?? "var(--revenue-chart-fill, var(--chart-1))";
const lineColor = line.color ?? "var(--primary)";
const plotWidth = 400;
const plotHeight = 140;
const slot = plotWidth / Math.max(rows.length, 1);
const points = rows.map((row, index) => ({
x: slot * (index + 0.5),
y: plotHeight - (valueOf(row, line.key) / lineMax) * plotHeight,
}));
const path = points
.map((point, index) => `${index ? "L" : "M"}${point.x},${point.y}`)
.join(" ");
return (
<Reveal
variant="card"
render={
<Card
role="region"
aria-label={title}
variant="muted"
className={cn("@container w-full min-w-0", className)}
/>
}
>
<div>
<CardHeader>
<div className="min-w-0">
<p className="truncate text-sm leading-5 font-medium text-muted-foreground">
{selected ? selected.label : title} · {line.label}{" "}
<Count value={shownLine} format={lineFormat} />
</p>
<div className="flex items-center gap-2">
<p className="text-2xl leading-8 font-medium tabular-nums">
<Count value={shownBar} format={barFormat} />
</p>
{change !== undefined && (
<Badge variant="label" tone={change < 0 ? "rose" : "lime"}>
{change > 0 ? "+" : ""}
{number(change * 100)}%
</Badge>
)}
</div>
</div>
{ranges && ranges.length > 0 && (
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="sm" />}
>
<RiCalendarLine data-icon="inline-start" />
{range?.label ?? ranges[0].label}
<RiArrowDownSLine
data-icon="inline-end"
className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn("w-40", popupClassName)}
>
<DropdownMenuGroup>
{ranges.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
setRangeId(item.id);
setActive(null);
}}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
)}
</CardHeader>
</div>
<div className="mt-1">
<CardContent
className="flex h-[184px] min-w-0 flex-row gap-1"
onMouseLeave={() => setActive(null)}
>
<div
aria-hidden
className="flex w-9 shrink-0 flex-col justify-between pb-6 text-right text-xs leading-3 text-muted-foreground"
>
{[3, 2, 1, 0].map((tick) => (
<span key={tick}>{compact((barMax * tick) / 3)}</span>
))}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<svg
viewBox={`0 0 ${plotWidth} ${plotHeight}`}
preserveAspectRatio="none"
className="h-[160px] w-full overflow-visible"
role="img"
aria-label={`${bar.label} and ${line.label} by category`}
>
{[0, 1, 2, 3].map((tick) => (
<line
key={tick}
x1="0"
x2={plotWidth}
y1={(plotHeight * tick) / 3}
y2={(plotHeight * tick) / 3}
stroke="var(--border)"
strokeDasharray="2 4"
vectorEffect="non-scaling-stroke"
/>
))}
{rows.map((row, index) => {
const height = (valueOf(row, bar.key) / barMax) * plotHeight;
const width = Math.min(slot * 0.55, 20);
return (
<rect
key={`${rangeId}-${row.label}-${index}`}
x={slot * (index + 0.5) - width / 2}
y={plotHeight - height}
width={width}
height={height}
rx={Math.min(7, width / 2)}
fill={barColor}
opacity={active === null || active === index ? 1 : 0.35}
stroke={focused === index ? "var(--ring)" : "none"}
strokeWidth="2"
vectorEffect="non-scaling-stroke"
className="combo-chart-card-01-bar origin-bottom [transform-box:fill-box] transition-opacity duration-150 motion-reduce:transition-none"
/>
);
})}
{/* The line, its halo and its dots sweep in from the left together. */}
<g
key={`line-${rangeId}-${lineColor}`}
className="combo-chart-card-01-sweep"
>
<path
d={path}
fill="none"
stroke="var(--surface-sunken, var(--muted))"
strokeWidth="5"
vectorEffect="non-scaling-stroke"
/>
<path
d={path}
fill="none"
stroke={lineColor}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{points.map((point, index) => (
<circle
key={index}
cx={point.x}
cy={point.y}
r={active === index ? 4 : 2.5}
fill={lineColor}
stroke="var(--surface-sunken, var(--muted))"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
className="transition-all duration-150 motion-reduce:transition-none"
/>
))}
</g>
{rows.map((row, index) => (
<rect
key={`hit-${index}`}
x={slot * index}
y="0"
width={slot}
height={plotHeight}
fill="transparent"
tabIndex={0}
role="img"
aria-label={`${row.label}: ${bar.label} ${barFormat(valueOf(row, bar.key))}, ${line.label} ${lineFormat(valueOf(row, line.key))}`}
className="outline-hidden"
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setActive(index)}
onFocus={() => {
setActive(index);
setFocused(index);
}}
onBlur={() => {
setActive(null);
setFocused(null);
}}
/>
))}
</svg>
<div
aria-hidden
className="mt-2 flex text-xs leading-4 text-muted-foreground"
>
{rows.map((row, index) => (
<span
key={`${row.label}-${index}`}
className="min-w-0 flex-1 overflow-hidden text-center whitespace-nowrap @max-[400px]:invisible"
>
{row.label}
</span>
))}
</div>
</div>
<div
aria-hidden
className="flex w-9 shrink-0 flex-col justify-between pb-6 text-xs leading-3 text-muted-foreground"
>
{[3, 2, 1, 0].map((tick) => (
<span key={tick}>{lineFormat((lineMax * tick) / 3)}</span>
))}
</div>
</CardContent>
</div>
{tiles && (
<div>
<CardFooter className="grid grid-cols-2 gap-2">
{[
{
label: `${bar.label} · total`,
value: <Count value={total} format={barFormat} />,
color: barColor,
},
{
label: `${line.label} · average`,
value: <Count value={average} format={lineFormat} />,
color: lineColor,
},
].map((tile) => (
<Card
variant="flat"
size="sm"
key={tile.label}
className="min-w-0"
>
<CardContent className="gap-1">
{/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: legend label is 13px */}
<div className="flex items-center gap-1.5 text-[13px] leading-4 text-muted-foreground">
<span
className="size-3 shrink-0 rounded-[3px] bg-(--tile-color)"
style={
{ "--tile-color": tile.color } as React.CSSProperties
}
/>
<span className="min-w-0 break-words">{tile.label}</span>
</div>
<p className="text-sm leading-4 font-medium tabular-nums">
{tile.value}
</p>
</CardContent>
</Card>
))}
</CardFooter>
</div>
)}
</Reveal>
);
} On this page