Paired Column Chart
A paired column chart comparing two series side by side: switching periods reorders and resizes the bars with a layout animation, and hovering shows the gap between the pair.
Places two series (say, this year and last year) side by side as a pair of bars for every category — built for period-over-period comparisons. A legend and a period segmented control sit on top; when the period changes, each pair is keyed by its category name and springs to its new sorted position while its bar heights resize in step. Categories that appear fade in and grow from zero; categories that disappear shrink away. Hovering a pair, or moving to it with the keyboard, shows a tooltip with both values and the percentage gap, and draws a gap bracket across the tops of the two bars.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add paired-column-chart.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<PairedColumnChart/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/paired-column-chart.en.jsonOr, once registries are configured in components.json, install it as @webberui/paired-column-chart.
Usage
import { PairedColumnChart } from "@/components/ui/paired-column-chart";
<PairedColumnChart
seriesLabels={{ a: "今年", b: "去年" }}
sortBy="a"
formatValue={(v) => `${v.toLocaleString("zh-TW")} 萬`}
onPeriodChange={(id) => console.log(`切換到 ${id}`)}
periods={[
{
id: "q1",
label: "第一季",
data: [
{ label: "信義店", a: 412, b: 380 },
{ label: "府城店", a: 298, b: 254 },
],
},
{
id: "h1",
label: "上半年",
data: [
{ label: "信義店", a: 790, b: 802 },
{ label: "府城店", a: 611, b: 520 },
],
},
]}
/>(The labels above read "this year" / "last year", "Q1" / "H1", and two branch names; 萬 is the 10,000 unit.)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
periods | PairedColumnPeriod[] | — | Switchable periods, each with its own set of category data |
seriesLabels | { a: string; b: string } | — | Legend names for the two series, also used in the tooltip and table headers |
period | string | — | Controlled current period id; when omitted the component manages it (uncontrolled) |
defaultPeriod | string | first period | Initial period id in uncontrolled mode |
onPeriodChange | (id: string) => void | — | Called when the period changes |
sortBy | "none" | "a" | "b" | "gap" | "a" | Category order: data order, A descending, B descending, or A's percentage gap over B descending |
height | number | 280 | Chart area height (px, including X-axis labels) |
showValues | boolean | false | Show a compact value above each bar |
formatValue | (value: number) => string | Intl.NumberFormat("zh-TW") | Value formatter for the tooltip and the screen-reader table; add currency and units here. The English registry variant defaults to "en-US" |
patternB | "solid" | "stripes" | "solid" | Fill style for series B: solid or diagonal stripes |
className | string | — | Passed through to the outermost container |
PairedColumnPeriod
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Period id |
label | string | — | Name shown in the segmented control |
data | PairedColumnDatum[] | — | Per-category data for this period |
PairedColumnDatum
| Field | Type | Default | Description |
|---|---|---|---|
label | string | — | Category name; identifies the same pair across periods, so keep it unique |
a | number | — | Series A value (negatives are treated as 0) |
b | number | — | Series B value (negatives are treated as 0) |
How it works
- The chart area uses flex and div bars instead of SVG rects: every pair is a motion element with
layout="position", so a change in sort order is tweened by the layout animation; categories that disappear leave throughAnimatePresenceinpopLayoutmode without holding up the other pairs. - The Y axis is always split into four intervals with a "readable" upper bound (steps land on 1 / 2 / 2.5 / 5 / 10 × a power of ten). Gridlines stay put across period switches and only the tick numbers change; ticks and
showValueslabels use compact notation (for example 12K). - The gap percentage is (A − B) ÷ B; when B is 0 it shows "—", and with
sortBy="gap"those items go first. - Timing can be overridden with CSS variables:
--wb-paired-column-chart-stiffness(spring stiffness, default 300, damping fixed at 30) and--wb-paired-column-chart-duration(first entrance duration, default 600ms). The component re-reads both before every period switch. - The first entrance fires when the chart scrolls into view: bars grow from zero with a 50ms stagger between pairs and B trailing A by 80ms; later height changes use the spring.
- Colors: A uses
--wb-paired-column-chart-a(default#0d9488) and--wb-paired-column-chart-a-darkin dark mode (default#2dd4bf); B uses the neutral scale.
Accessibility
- The period segmented control is a
role="radiogroup"; each option is arole="radio"witharia-checkedand a roving tabindex, switched with ← / → (or ↑ / ↓) and Home / End - The chart area is focusable (
role="group", with a chart summary as itsaria-label); once focused, ← / → move the hovered pair, Home / End jump to the ends, and Esc clears; the current pair's values and gap are announced through anaria-liveregion - A sr-only
<table>version of the data (category, both series, gap) is linked to the chart area viaaria-describedby; the visual bars themselves are hidden from assistive tech - Both the segmented control and the chart area have a focus-visible ring
- When the system "reduce motion" setting is on, bars render at their final height and sorting and period switches land instantly; the tooltip and gap bracket still work
Summary Line Chart
A React multi-line chart with a live summary column: lines draw in with a stroke animation, and as the pointer moves the summary's values and change rates roll digit by digit to that point in time. Click a summary row to hide a line and the Y axis re-tweens.
Spectrum Monitor
A live scrolling spectrum and heat band: a canvas waterfall of band intensities drawn frame by frame, with pause, hover readouts and automatic pausing when off screen.