- 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
Audience journey
2,400+6.4%
- Visitors
- 2,400
- Explored
- 1,320
- Saved
- 780
- Subscribed
- 350
Audience journey
2,400
- Visitors
- 2,400
- Explored
- 1,320
- Saved
- 780
- Subscribed
- 350
Audience journey
2,400
- Visitors
- 2,400
- Explored
- 1,320
- Saved
- 780
- Subscribed
- 350
Audience journey
2,400
- Visitors
- 2,400
- Explored
- 1,320
- Saved
- 780
- Subscribed
- 350
Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/funnel-chart-card-01
Pass stages as an ordered list of labels and values. Percentages are relative to the first stage. Use shape="sharp" for straight edges, mono for a single color, and ranges for a period selector. Each range can supply its own stages, headline, and fractional delta. Set popupClassName when a scoped theme must also apply to the menu.
Source
"use client";
import { RiArrowDownSLine, RiCalendarLine } 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,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";
export interface FunnelStage {
label: string;
value: number;
color?: string;
}
export interface FunnelRange {
label: string;
stages: FunnelStage[];
delta?: number;
headline?: number;
}
const colors = [
"var(--funnel-chart-1, var(--revenue-chart-fill, var(--chart-1)))",
"var(--funnel-chart-2, var(--primary))",
"var(--funnel-chart-3, var(--chart-5))",
"var(--funnel-chart-4, var(--chart-3))",
"var(--funnel-chart-5, var(--chart-4))",
];
// Slices squash open from the middle (450ms), each 40ms after the previous
// one, so the funnel reads as drawn rather than switched on.
const segmentIn = "funnel-chart-card-01-segment-in";
export function FunnelChartCard({
title,
stages,
ranges,
delta,
headline,
shape = "curved",
mono = false,
className,
popupClassName,
}: {
title: string;
stages: FunnelStage[];
ranges?: FunnelRange[];
delta?: number;
headline?: number;
shape?: "curved" | "sharp";
mono?: boolean;
className?: string;
popupClassName?: string;
}) {
const [rangeIndex, setRangeIndex] = React.useState(0);
const [active, setActive] = React.useState<number | null>(null);
const range = ranges?.[rangeIndex];
const data = range?.stages ?? stages;
const first = Math.max(0, data[0]?.value ?? 0);
const selected = active === null ? undefined : data[active];
const total = selected?.value ?? range?.headline ?? headline ?? first;
const change = range?.delta ?? delta;
const totalRef = useCountUp<HTMLSpanElement>(total);
const height = (value: number) =>
first > 0 ? Math.max(0, Math.min(100, (value / first) * 100)) : 0;
return (
<Reveal
variant="card"
render={
<Card
radius={16}
variant="muted"
role="region"
aria-label={title}
// oxlint-disable-next-line shadcn/no-restyle -- reference: 20px between header, funnel and legend
className={cn("@container/funnel min-w-0 gap-5", className)}
/>
}
>
<CardHeader className="min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<CardTitle variant="secondary" className="min-w-0">
<span className="block truncate">{selected?.label ?? title}</span>
</CardTitle>
<div className="flex min-w-0 items-center gap-2">
<span
ref={totalRef}
className="text-2xl leading-8 font-medium tabular-nums"
>
{total.toLocaleString("en-US")}
</span>
{active === null && change !== undefined && (
<Badge variant="label" tone={change < 0 ? "rose" : "lime"}>
{change > 0 ? "+" : ""}
{(change * 100).toFixed(1)}%
</Badge>
)}
</div>
</div>
{ranges && ranges.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Range: ${range?.label ?? ""}`}
render={<Button variant="outline" size="sm" />}
>
<RiCalendarLine data-icon="inline-start" />
{/* In a narrow card the label is dropped instead of squeezed to
one letter; the menu trigger keeps its aria-label. */}
<span className="hidden @min-[220px]/card-header:inline">
{range?.label}
</span>
<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>
<DropdownMenuRadioGroup
value={String(rangeIndex)}
onValueChange={(value) => {
setRangeIndex(Number(value));
setActive(null);
}}
>
{ranges.map((item, index) => (
<DropdownMenuRadioItem
key={item.label}
value={String(index)}
closeOnClick
>
{item.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardHeader>
{data.length > 0 && (
<CardContent
className="flex h-[148px] min-w-0 flex-row gap-0"
onMouseLeave={() => setActive(null)}
>
<div key={rangeIndex} className="flex h-full w-full min-w-0">
{data.map((stage, index) => {
const h = height(stage.value);
const next = height(data[index + 1]?.value ?? stage.value);
const top = (100 - h) / 2;
const bottom = 100 - top;
const nextTop = (100 - next) / 2;
const nextBottom = 100 - nextTop;
const path =
shape === "sharp"
? `M 0 ${top} L 100 ${nextTop} L 100 ${nextBottom} L 0 ${bottom} Z`
: `M 0 ${top} C 55 ${top} 55 ${nextTop} 100 ${nextTop} L 100 ${nextBottom} C 55 ${nextBottom} 55 ${bottom} 0 ${bottom} Z`;
const color = mono
? "var(--funnel-chart-mono, var(--muted-foreground))"
: (stage.color ?? colors[index % colors.length]);
const faded = active !== null && active !== index;
return (
<Button
key={`${stage.label}-${index}`}
type="button"
variant="unstyled"
focusRing="none"
onMouseDown={(event) => event.preventDefault()}
aria-label={`${stage.label}: ${stage.value.toLocaleString()}, ${Math.round(h)} percent of first stage`}
onMouseEnter={() => setActive(index)}
onFocus={() => setActive(index)}
onBlur={() => setActive(null)}
className="relative h-full flex-1 cursor-default"
>
<span
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 ease-out motion-reduce:transition-none",
faded && "opacity-50"
)}
>
<svg
aria-hidden="true"
viewBox="0 0 100 100"
preserveAspectRatio="none"
style={
{ "--delay": `${index * 40}ms` } as React.CSSProperties
}
className={cn(
"absolute inset-0 size-full origin-center overflow-visible",
segmentIn
)}
>
<path
d={path}
fill="none"
opacity={shape === "curved" ? 0.1 : 0}
stroke={color}
strokeWidth="12"
vectorEffect="non-scaling-stroke"
/>
<path
d={path}
fill="none"
opacity={shape === "curved" ? 0.2 : 0}
stroke={color}
strokeWidth="6"
vectorEffect="non-scaling-stroke"
/>
<path
d={path}
fill={color}
vectorEffect="non-scaling-stroke"
className="group-focus-visible/button:stroke-ring group-focus-visible/button:stroke-2"
/>
</svg>
<span className="relative rounded-full bg-background px-2 py-1 text-xs leading-none font-medium text-foreground tabular-nums">
{Math.round(h)}%
</span>
</span>
</Button>
);
})}
</div>
</CardContent>
)}
<CardFooter className="min-w-0">
<dl className="grid min-w-0 flex-1 grid-cols-2 gap-1 @min-[340px]/funnel:grid-cols-4">
{data.map((stage, index) => (
<div
key={`${stage.label}-${index}`}
className="min-w-0 rounded-[8px] bg-(--stat-footer-surface,var(--background)) px-2 py-2"
>
{/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: legend tile label is 13px */}
<dt className="flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground">
{!mono && (
<span
aria-hidden
className="size-2 shrink-0 rounded-[2px] bg-(--swatch)"
style={
{
"--swatch":
stage.color ?? colors[index % colors.length],
} as React.CSSProperties
}
/>
)}
<span className="truncate">{stage.label}</span>
</dt>
<dd className="mt-0.5 text-sm font-medium tabular-nums">
{stage.value.toLocaleString()}
</dd>
</div>
))}
</dl>
</CardFooter>
</Reveal>
);
} On this page