Data Storytelling

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.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

280
300
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
periodsPairedColumnPeriod[]—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
periodstring—Controlled current period id; when omitted the component manages it (uncontrolled)
defaultPeriodstringfirst periodInitial 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
heightnumber280Chart area height (px, including X-axis labels)
showValuesbooleanfalseShow a compact value above each bar
formatValue(value: number) => stringIntl.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
classNamestring—Passed through to the outermost container

PairedColumnPeriod

FieldTypeDefaultDescription
idstring—Period id
labelstring—Name shown in the segmented control
dataPairedColumnDatum[]—Per-category data for this period

PairedColumnDatum

FieldTypeDefaultDescription
labelstring—Category name; identifies the same pair across periods, so keep it unique
anumber—Series A value (negatives are treated as 0)
bnumber—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 through AnimatePresence in popLayout mode 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 showValues labels 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-dark in dark mode (default #2dd4bf); B uses the neutral scale.

Accessibility

  • The period segmented control is a role="radiogroup"; each option is a role="radio" with aria-checked and a roving tabindex, switched with ← / → (or ↑ / ↓) and Home / End
  • The chart area is focusable (role="group", with a chart summary as its aria-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 an aria-live region
  • A sr-only <table> version of the data (category, both series, gap) is linked to the chart area via aria-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