Stage Bars
Pipeline stage progress card.
Stages
Pipeline stage progress.
Pipeline
1,180
+2.4%Visits
1,180100%
Sign-up
79067%
Active
46039%
Pro
25021%
Team
12010%
Enterprise
403%
Visits
1,180Sign-up
790Active
460Pro
250Team
120Enterprise
40function StageBarsDemo() {
return <StageBarsCard stages={STAGE_BARS_STAGES} ranges={STAGE_BARS_RANGES} />;
}function StageBarsDemo() {
return <StageBarsCard stages={STAGE_BARS_STAGES} ranges={STAGE_BARS_RANGES} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/stage-bars-card.jsonnpx shadcn@latest add https://boardcn.dev/r/stage-bars-card.jsonBoardCN 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 {
useEffect,
useState,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp, usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export interface StageBarsStage {
label: string;
value: number;
/** Any CSS color. The BoardCN chart palette is used when omitted. */
color?: string;
/** Hover/focus color. A custom color is darkened when omitted. */
activeColor?: string;
/** Optional 14px icon rendered at the left edge of the stage pill. */
icon?: ReactNode;
}
export interface StageBarsRange {
id: string;
label: string;
stages: readonly StageBarsStage[];
/** Decimal change, for example `0.024` renders as `+2.4%`. */
delta?: number;
/** Resting headline. Defaults to the first stage's value. */
headline?: number;
}
export interface StageBarsCardProps
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
title?: string;
stages: readonly StageBarsStage[];
mono?: boolean;
showIcons?: boolean;
headline?: number;
delta?: number;
/** Static period pill. `ranges` takes precedence when both are supplied. */
range?: string;
ranges?: readonly StageBarsRange[];
defaultRange?: string;
onRangeChange?: (rangeId: string) => void;
format?: (value: number) => string;
}
type Tone = { color: string; activeColor: string };
const PALETTE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const PALETTE: readonly Tone[] = PALETTE_ORDER.map((tone) => ({
color: `var(--color-chart-${tone})`,
activeColor: `var(--color-chart-${tone}-active)`,
}));
const MONO_TONE: Tone = {
color:
"light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};
const OPACITY_TRANSITION =
"transition-opacity duration-200 ease-out motion-reduce:transition-none";
export function formatStageBarsNumber(value: number): string {
return value.toLocaleString("en-US");
}
function resolveTone(stage: StageBarsStage, index: number, mono: boolean): Tone {
if (mono) return MONO_TONE;
if (stage.color) {
return {
color: stage.color,
activeColor:
stage.activeColor ??
`color-mix(in srgb, ${stage.color} 82%, black)`,
};
}
return PALETTE[index % PALETTE.length];
}
function describeDelta(delta: number): {
label: string;
color: "neutral" | "lime" | "rose";
} {
const percentage = Math.round(Math.abs(delta) * 1000) / 10;
if (percentage === 0) return { label: "0.0%", color: "neutral" };
return {
label: `${delta > 0 ? "+" : "-"}${percentage}%`,
color: delta > 0 ? "lime" : "rose",
};
}
function useAnimatedNumber(value: number): number {
const precision = Number.isInteger(value)
? 0
: Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
? 1
: 2;
const multiplier = 10 ** precision;
return useCountUp(Math.round(value * multiplier)) / multiplier;
}
function activateWithKeyboard(
event: KeyboardEvent<HTMLElement>,
activate: () => void,
clear: () => void,
) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
} else if (event.key === "Escape") {
event.preventDefault();
clear();
event.currentTarget.blur();
}
}
/**
* BoardCN's stage funnel rendered as rows of animated rounded bars and a
* three-column stat-tile grid. Hovering or focusing either representation
* follows the active stage in the headline and dims the remaining stages.
*/
export function StageBarsCard({
title = "Pipeline",
stages,
mono = false,
showIcons = true,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
format = formatStageBarsNumber,
className,
...props
}: StageBarsCardProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
const reducedMotion = usePrefersReducedMotion();
const [revealed, setRevealed] = useState(reducedMotion);
const availableRanges = ranges;
const selectedRange =
availableRanges?.find((item) => item.id === selectedRangeId) ??
availableRanges?.[0];
const resolvedStages = selectedRange?.stages ?? stages;
const resolvedHeadline = selectedRange?.headline ?? headline;
const resolvedDelta = selectedRange?.delta ?? delta;
const tones = resolvedStages.map((stage, index) =>
resolveTone(stage, index, mono),
);
const topValue = Math.max(1, resolvedStages[0]?.value ?? 1);
const hasActiveStage = activeIndex !== null && activeIndex < resolvedStages.length;
const headerLabel = hasActiveStage ? resolvedStages[activeIndex].label : title;
const headerValue = hasActiveStage
? resolvedStages[activeIndex].value
: (resolvedHeadline ?? topValue);
const animatedHeadline = useAnimatedNumber(headerValue);
const deltaDescription =
resolvedDelta === undefined ? undefined : describeDelta(resolvedDelta);
useEffect(() => {
if (reducedMotion) {
setRevealed(true);
return;
}
const timer = window.setTimeout(() => setRevealed(true), 60);
return () => window.clearTimeout(timer);
}, [reducedMotion]);
function selectRange(id: string) {
setActiveIndex(null);
setSelectedRangeId(id);
onRangeChange?.(id);
}
return (
<section
className={cx(
"flex h-auto min-h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
className,
)}
aria-label={`${title} stage bars`}
{...props}
>
<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">
{headerLabel}
</p>
<div className="flex w-full items-center gap-2">
<p
key={`${selectedRange?.id ?? ""}:${activeIndex}`}
className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums motion-reduce:animate-none"
aria-live="polite"
>
{format(animatedHeadline)}
</p>
{deltaDescription && (
<Chip
variant="bold"
color={deltaDescription.color}
className={hasActiveStage ? "invisible" : undefined}
>
{deltaDescription.label}
</Chip>
)}
</div>
</div>
{availableRanges?.length ? (
<ChartRangeControl
ranges={availableRanges}
value={selectedRange?.id}
onValueChange={selectRange}
/>
) : range ? (
<ChartRangeControl label={range} />
) : null}
</div>
<div
className="grid min-h-0 w-full flex-1 content-center items-center gap-x-3 gap-y-3 py-2"
style={{
gridTemplateColumns: "auto minmax(0, 1fr) auto",
gridTemplateRows: `repeat(${Math.max(1, resolvedStages.length)}, auto)`,
}}
role="list"
aria-label={`${title} stages`}
>
{resolvedStages.map((stage, index) => {
const active = activeIndex === index;
const dimmed = hasActiveStage && !active;
const share = Math.round((stage.value / topValue) * 100);
return (
<div
key={`${stage.label}-${index}`}
className="group contents outline-none"
role="listitem"
tabIndex={0}
aria-label={`${stage.label}: ${format(stage.value)}, ${share}%`}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(null)}
onKeyDown={(event) =>
activateWithKeyboard(
event,
() => setActiveIndex(index),
() => setActiveIndex(null),
)
}
>
<span
className={cx(
"text-right text-body-regular whitespace-nowrap text-text-secondary",
OPACITY_TRANSITION,
)}
style={{ opacity: dimmed ? 0.35 : 1 }}
aria-hidden
>
{stage.label}
</span>
<div
className={cx(
"relative h-5 min-w-0 overflow-hidden rounded-full bg-chart-track ring-offset-1 ring-offset-background-secondary-default group-focus-visible:ring-2 group-focus-visible:ring-border-focus-ring",
OPACITY_TRANSITION,
)}
style={{ opacity: dimmed ? 0.35 : 1 }}
aria-hidden
>
<div
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
data-testid={`stage-bar-${stage.label}`}
style={{
width: revealed
? `${Math.max(2, (stage.value / topValue) * 100)}%`
: 0,
backgroundColor: active
? tones[index].activeColor
: tones[index].color,
}}
/>
{showIcons && stage.icon && (
<span className="pointer-events-none absolute inset-y-0 left-1 flex items-center">
{stage.icon}
</span>
)}
</div>
<span
className={cx(
"flex items-baseline justify-end gap-1.5 whitespace-nowrap",
OPACITY_TRANSITION,
)}
style={{ opacity: dimmed ? 0.35 : 1 }}
aria-hidden
>
<span className="text-body-medium text-text-primary tabular-nums">
{format(stage.value)}
</span>
<span className="text-caption-1-medium text-text-tertiary tabular-nums">
{share}%
</span>
</span>
</div>
);
})}
</div>
<div className="-mx-2 -mb-1 grid grid-cols-3 gap-2" role="list">
{resolvedStages.map((stage, index) => {
const active = activeIndex === index;
const dimmed = hasActiveStage && !active;
return (
<div
key={`${stage.label}-${index}`}
className="flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 outline-none ring-offset-1 ring-offset-background-secondary-default transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-border-focus-ring motion-reduce:transition-none"
style={{ opacity: dimmed ? 0.5 : 1 }}
role="listitem"
tabIndex={0}
aria-label={`${stage.label}: ${format(stage.value)}`}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(null)}
onKeyDown={(event) =>
activateWithKeyboard(
event,
() => setActiveIndex(index),
() => setActiveIndex(null),
)
}
>
<div className="flex min-w-0 max-w-full items-center gap-1.5" aria-hidden>
{!mono && (
<span
className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out motion-reduce:transition-none"
style={{
backgroundColor: active
? tones[index].activeColor
: tones[index].color,
}}
/>
)}
<span className="truncate text-body-regular text-text-secondary">
{stage.label}
</span>
</div>
<span
className="text-body-medium whitespace-nowrap text-text-primary tabular-nums"
aria-hidden
>
{format(stage.value)}
</span>
</div>
);
})}
</div>
</section>
);
}"use client";
import {
useEffect,
useState,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp, usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export interface StageBarsStage {
label: string;
value: number;
/** Any CSS color. The BoardCN chart palette is used when omitted. */
color?: string;
/** Hover/focus color. A custom color is darkened when omitted. */
activeColor?: string;
/** Optional 14px icon rendered at the left edge of the stage pill. */
icon?: ReactNode;
}
export interface StageBarsRange {
id: string;
label: string;
stages: readonly StageBarsStage[];
/** Decimal change, for example `0.024` renders as `+2.4%`. */
delta?: number;
/** Resting headline. Defaults to the first stage's value. */
headline?: number;
}
export interface StageBarsCardProps
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
title?: string;
stages: readonly StageBarsStage[];
mono?: boolean;
showIcons?: boolean;
headline?: number;
delta?: number;
/** Static period pill. `ranges` takes precedence when both are supplied. */
range?: string;
ranges?: readonly StageBarsRange[];
defaultRange?: string;
onRangeChange?: (rangeId: string) => void;
format?: (value: number) => string;
}
type Tone = { color: string; activeColor: string };
const PALETTE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const PALETTE: readonly Tone[] = PALETTE_ORDER.map((tone) => ({
color: `var(--color-chart-${tone})`,
activeColor: `var(--color-chart-${tone}-active)`,
}));
const MONO_TONE: Tone = {
color:
"light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};
const OPACITY_TRANSITION =
"transition-opacity duration-200 ease-out motion-reduce:transition-none";
export function formatStageBarsNumber(value: number): string {
return value.toLocaleString("en-US");
}
function resolveTone(stage: StageBarsStage, index: number, mono: boolean): Tone {
if (mono) return MONO_TONE;
if (stage.color) {
return {
color: stage.color,
activeColor:
stage.activeColor ??
`color-mix(in srgb, ${stage.color} 82%, black)`,
};
}
return PALETTE[index % PALETTE.length];
}
function describeDelta(delta: number): {
label: string;
color: "neutral" | "lime" | "rose";
} {
const percentage = Math.round(Math.abs(delta) * 1000) / 10;
if (percentage === 0) return { label: "0.0%", color: "neutral" };
return {
label: `${delta > 0 ? "+" : "-"}${percentage}%`,
color: delta > 0 ? "lime" : "rose",
};
}
function useAnimatedNumber(value: number): number {
const precision = Number.isInteger(value)
? 0
: Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
? 1
: 2;
const multiplier = 10 ** precision;
return useCountUp(Math.round(value * multiplier)) / multiplier;
}
function activateWithKeyboard(
event: KeyboardEvent<HTMLElement>,
activate: () => void,
clear: () => void,
) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
} else if (event.key === "Escape") {
event.preventDefault();
clear();
event.currentTarget.blur();
}
}
/**
* BoardCN's stage funnel rendered as rows of animated rounded bars and a
* three-column stat-tile grid. Hovering or focusing either representation
* follows the active stage in the headline and dims the remaining stages.
*/
export function StageBarsCard({
title = "Pipeline",
stages,
mono = false,
showIcons = true,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
format = formatStageBarsNumber,
className,
...props
}: StageBarsCardProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
const reducedMotion = usePrefersReducedMotion();
const [revealed, setRevealed] = useState(reducedMotion);
const availableRanges = ranges;
const selectedRange =
availableRanges?.find((item) => item.id === selectedRangeId) ??
availableRanges?.[0];
const resolvedStages = selectedRange?.stages ?? stages;
const resolvedHeadline = selectedRange?.headline ?? headline;
const resolvedDelta = selectedRange?.delta ?? delta;
const tones = resolvedStages.map((stage, index) =>
resolveTone(stage, index, mono),
);
const topValue = Math.max(1, resolvedStages[0]?.value ?? 1);
const hasActiveStage = activeIndex !== null && activeIndex < resolvedStages.length;
const headerLabel = hasActiveStage ? resolvedStages[activeIndex].label : title;
const headerValue = hasActiveStage
? resolvedStages[activeIndex].value
: (resolvedHeadline ?? topValue);
const animatedHeadline = useAnimatedNumber(headerValue);
const deltaDescription =
resolvedDelta === undefined ? undefined : describeDelta(resolvedDelta);
useEffect(() => {
if (reducedMotion) {
setRevealed(true);
return;
}
const timer = window.setTimeout(() => setRevealed(true), 60);
return () => window.clearTimeout(timer);
}, [reducedMotion]);
function selectRange(id: string) {
setActiveIndex(null);
setSelectedRangeId(id);
onRangeChange?.(id);
}
return (
<section
className={cx(
"flex h-auto min-h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
className,
)}
aria-label={`${title} stage bars`}
{...props}
>
<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">
{headerLabel}
</p>
<div className="flex w-full items-center gap-2">
<p
key={`${selectedRange?.id ?? ""}:${activeIndex}`}
className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums motion-reduce:animate-none"
aria-live="polite"
>
{format(animatedHeadline)}
</p>
{deltaDescription && (
<Chip
variant="bold"
color={deltaDescription.color}
className={hasActiveStage ? "invisible" : undefined}
>
{deltaDescription.label}
</Chip>
)}
</div>
</div>
{availableRanges?.length ? (
<ChartRangeControl
ranges={availableRanges}
value={selectedRange?.id}
onValueChange={selectRange}
/>
) : range ? (
<ChartRangeControl label={range} />
) : null}
</div>
<div
className="grid min-h-0 w-full flex-1 content-center items-center gap-x-3 gap-y-3 py-2"
style={{
gridTemplateColumns: "auto minmax(0, 1fr) auto",
gridTemplateRows: `repeat(${Math.max(1, resolvedStages.length)}, auto)`,
}}
role="list"
aria-label={`${title} stages`}
>
{resolvedStages.map((stage, index) => {
const active = activeIndex === index;
const dimmed = hasActiveStage && !active;
const share = Math.round((stage.value / topValue) * 100);
return (
<div
key={`${stage.label}-${index}`}
className="group contents outline-none"
role="listitem"
tabIndex={0}
aria-label={`${stage.label}: ${format(stage.value)}, ${share}%`}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(null)}
onKeyDown={(event) =>
activateWithKeyboard(
event,
() => setActiveIndex(index),
() => setActiveIndex(null),
)
}
>
<span
className={cx(
"text-right text-body-regular whitespace-nowrap text-text-secondary",
OPACITY_TRANSITION,
)}
style={{ opacity: dimmed ? 0.35 : 1 }}
aria-hidden
>
{stage.label}
</span>
<div
className={cx(
"relative h-5 min-w-0 overflow-hidden rounded-full bg-chart-track ring-offset-1 ring-offset-background-secondary-default group-focus-visible:ring-2 group-focus-visible:ring-border-focus-ring",
OPACITY_TRANSITION,
)}
style={{ opacity: dimmed ? 0.35 : 1 }}
aria-hidden
>
<div
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
data-testid={`stage-bar-${stage.label}`}
style={{
width: revealed
? `${Math.max(2, (stage.value / topValue) * 100)}%`
: 0,
backgroundColor: active
? tones[index].activeColor
: tones[index].color,
}}
/>
{showIcons && stage.icon && (
<span className="pointer-events-none absolute inset-y-0 left-1 flex items-center">
{stage.icon}
</span>
)}
</div>
<span
className={cx(
"flex items-baseline justify-end gap-1.5 whitespace-nowrap",
OPACITY_TRANSITION,
)}
style={{ opacity: dimmed ? 0.35 : 1 }}
aria-hidden
>
<span className="text-body-medium text-text-primary tabular-nums">
{format(stage.value)}
</span>
<span className="text-caption-1-medium text-text-tertiary tabular-nums">
{share}%
</span>
</span>
</div>
);
})}
</div>
<div className="-mx-2 -mb-1 grid grid-cols-3 gap-2" role="list">
{resolvedStages.map((stage, index) => {
const active = activeIndex === index;
const dimmed = hasActiveStage && !active;
return (
<div
key={`${stage.label}-${index}`}
className="flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 outline-none ring-offset-1 ring-offset-background-secondary-default transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-border-focus-ring motion-reduce:transition-none"
style={{ opacity: dimmed ? 0.5 : 1 }}
role="listitem"
tabIndex={0}
aria-label={`${stage.label}: ${format(stage.value)}`}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(null)}
onKeyDown={(event) =>
activateWithKeyboard(
event,
() => setActiveIndex(index),
() => setActiveIndex(null),
)
}
>
<div className="flex min-w-0 max-w-full items-center gap-1.5" aria-hidden>
{!mono && (
<span
className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out motion-reduce:transition-none"
style={{
backgroundColor: active
? tones[index].activeColor
: tones[index].color,
}}
/>
)}
<span className="truncate text-body-regular text-text-secondary">
{stage.label}
</span>
</div>
<span
className="text-body-medium whitespace-nowrap text-text-primary tabular-nums"
aria-hidden
>
{format(stage.value)}
</span>
</div>
);
})}
</div>
</section>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
StageBarsCard
BoardCN's stage funnel rendered as rows of animated rounded bars and a three-column stat-tile grid. Hovering or focusing either representation follows the active stage in the headline and dims the remaining stages.
| Prop | Type | Default | Description |
|---|---|---|---|
| stagesrequired | readonly StageBarsStage[] | — | — |
| defaultRange | string | — | — |
| delta | number | — | — |
| format | (value: number) => string | — | — |
| headline | number | — | — |
| mono | boolean | false | — |
| onRangeChange | (rangeId: string) => void | — | — |
| range | string | — | Static period pill. `ranges` takes precedence when both are supplied. |
| ranges | readonly StageBarsRange[] | — | — |
| showIcons | boolean | true | — |
| title | string | Pipeline | — |