Stacked Bars
A stacked bar chart with a Day/Week toggle; bars regrow with a stagger and the total follows your cursor.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/stacked-bars.json'use client'
import { useId, useMemo, useState } from 'react'
import './stacked-bars.css'
export interface BarSeries {
label: string
color: string
}
export interface StackedBarsProps {
title?: string
unit?: string
series: BarSeries[]
/** Datasets by range key; each entry is one bar with a value per series. */
data: Record<string, number[][]>
ranges?: { key: string; label: string }[]
fill?: 'dark' | 'light'
className?: string
}
/** A stacked bar chart with a range toggle. Bars regrow with a stagger when the range changes. */
export function StackedBars({ title = 'Traffic', unit = 'visits', series, data, ranges = [{ key: 'day', label: 'Day' }, { key: 'week', label: 'Week' }], fill = 'dark', className }: StackedBarsProps) {
const [range, setRange] = useState(ranges[ranges.length - 1].key)
const [hover, setHover] = useState<number | null>(null)
const id = useId()
const bars = data[range] ?? []
const max = useMemo(() => Math.max(1, ...bars.map((b) => b.reduce((s, x) => s + x, 0))), [bars])
const total = bars.reduce((s, b) => s + b.reduce((a, x) => a + x, 0), 0)
const shown = hover !== null && bars[hover] ? bars[hover].reduce((a, x) => a + x, 0) : total
return (
<figure className={['sd-sb', className].filter(Boolean).join(' ')} data-fill={fill} aria-labelledby={`${id}-t`}>
<header className="sd-sb__head">
<div>
<figcaption id={`${id}-t`} className="sd-sb__title">{title}</figcaption>
<p className="sd-sb__num" aria-live="polite">
{shown.toLocaleString('en-US')} <span>{hover !== null ? `bar ${hover + 1}` : unit}</span>
</p>
</div>
<div role="radiogroup" aria-label="Range" className="sd-sb__seg">
{ranges.map((r) => (
<button key={r.key} type="button" role="radio" aria-checked={range === r.key} onClick={() => setRange(r.key)}>
{r.label}
</button>
))}
</div>
</header>
<div className="sd-sb__plot" role="list" aria-label={`${title} by ${range}`} onPointerLeave={() => setHover(null)}>
{bars.map((b, i) => {
const sum = b.reduce((s, x) => s + x, 0)
return (
<div
key={i}
role="listitem"
tabIndex={0}
aria-label={`Bar ${i + 1}: ${series.map((s, j) => `${s.label} ${b[j]}`).join(', ')}`}
className="sd-sb__bar"
data-dim={(hover !== null && hover !== i) || undefined}
style={{ height: `${(sum / max) * 100}%`, '--d': `${i * 18}ms` } as React.CSSProperties}
onPointerEnter={() => setHover(i)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
>
{b.map((v, j) => (
<span key={j} style={{ flexGrow: v, background: series[j].color }} />
))}
</div>
)
})}
</div>
<ul className="sd-sb__legend">
{series.map((s) => (
<li key={s.label}>
<i style={{ background: s.color }} aria-hidden />
{s.label}
</li>
))}
</ul>
</figure>
)
}.sd-sb { --bg: #1d1d1d; --fg: #f7f7f7; --mute: #8e8e8e; --soft: rgb(255 255 255 / 0.07); --line: rgb(255 255 255 / 0.09);
width: min(100%, 330px); margin: 0; padding: 16px 16px 14px; border-radius: 22px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); font-feature-settings: 'tnum'; }
.sd-sb[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --soft: rgb(0 0 0 / 0.05); --line: rgb(0 0 0 / 0.08); }
.sd-sb__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.sd-sb__title { font-size: 13.5px; color: var(--mute); }
.sd-sb__num { margin: 4px 0 0; font-size: 32px; font-weight: 600; letter-spacing: -0.04em; line-height: 1; }
.sd-sb__num span { font-size: 12.5px; font-weight: 400; letter-spacing: 0; color: var(--mute); }
.sd-sb__seg { display: flex; padding: 3px; border-radius: 999px; background: var(--soft); }
.sd-sb__seg button { height: 26px; padding: 0 11px; border-radius: 999px; font-size: 12.5px; color: var(--mute); transition: background-color 250ms, color 250ms; }
.sd-sb__seg button[aria-checked='true'] { background: var(--fg); color: var(--bg); }
.sd-sb__plot { display: flex; align-items: flex-end; gap: 4px; height: 104px; margin-top: 14px; }
.sd-sb__bar { flex: 1; display: flex; flex-direction: column-reverse; gap: 2px; min-height: 6px; border-radius: 4px; outline: none;
transition: height 650ms cubic-bezier(0.2, 1.2, 0.3, 1) var(--d), opacity 200ms; }
.sd-sb__bar span { border-radius: 3px; min-height: 3px; }
.sd-sb__bar[data-dim] { opacity: 0.35; }
.sd-sb__bar:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-sb__legend { display: flex; gap: 12px; margin: 12px 0 0; padding: 0; list-style: none; font-size: 12px; color: var(--mute); }
.sd-sb__legend li { display: flex; align-items: center; gap: 5px; }
.sd-sb__legend i { width: 8px; height: 8px; border-radius: 3px; }
.sd-sb button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .sd-sb * { transition: none !important; } }Overview
Four series stack into each bar. Switching range animates every bar to its new height in a quick left-to-right wave.
Hovering or focusing a bar dims the others and swaps the headline total for that bar’s value.
When to use
- Dashboard tiles that compare a few sources over time.
- Places where a quick range switch matters more than exact axes.
When not to use
- Precise reading of values: add axes or use a table.
- More than five series.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/stacked-bars.json$
npx shadcn@latest add https://sundaeui.dev/r/stacked-bars.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/stacked-bars.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/stacked-bars.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy stacked-bars.tsx and stacked-bars.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { StackedBars } from '@/components/sundae/stacked-bars'
<StackedBars
title="Traffic"
series={[{ label: 'Direct', color: '#fafafa' }, { label: 'Search', color: '#737373' }]}
data={{ day: dayBars, week: weekBars }}
/>Props
| Prop | Type | Default |
|---|---|---|
series*Label and colour per stacked series. | BarSeries[] | — |
data*Bars per range key; one value per series. | Record<string, number[][]> | — |
rangesRange toggle options. | { key; label }[] | Day, Week |
title / unitHeading and unit. | string | 'Traffic' / 'visits' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The range toggle is a radiogroup.
- Each bar is focusable with an aria-label listing every series value.
- The headline total is a polite live region.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Tab
- Move through bars
- Enter / Space
- Choose a range
FAQ
- Is Stacked Bars free?
- Yes. Stacked Bars is free for personal and commercial projects, with full source on this page.
- How do I install Stacked Bars?
- Run npx shadcn@latest add https://sundaeui.dev/r/stacked-bars.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Stacked Bars support keyboard and screen readers?
- The range toggle is a radiogroup. Each bar is focusable with an aria-label listing every series value.
- Does Stacked Bars respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All data →- Pro
Scrub Sparkline
Data · Refined - Pro
Scatter Plot
Data · Refined - Pro
Funnel Chart
Data · Refined - Pro
Histogram
Data · Refined Pulse KPI
Data · Understated- Pro
Activity Rings
Data · Expressive