Data Storytelling

Balance Chart

A diverging bar chart React component centred on a zero axis: income grows up and expenses grow down, a net line draws in once the bars land, and hover or keyboard focus shows the period's income, expenses and net.

Plot each period's income and expenses on either side of one zero axis: income bars grow up, expense bars grow down, so the periods that ran at a loss stand out at a glance. On entry the bars grow out of the axis one after another, then the net line draws in. Hover any column, or focus the chart and use the arrow keys, and that column gets a light band while the others fade back; the tooltip lists income, expenses and a net figure that tweens from its previous value. It defaults to a dark panel, and surface="auto" follows the site's light/dark mode instead. Pure hand-drawn SVG, no chart library.

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 balance-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
700
50
Active edited usageDefault props
<BalanceChart/>

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/balance-chart.en.json

Or, once registries are configured in components.json, install it as @webberui/balance-chart.

Usage

import { BalanceChart } from "@/components/ui/balance-chart";

const data = [
  { label: "1月", income: 168000, expense: 142500 },
  { label: "2月", income: 121000, expense: 133800 },
  { label: "3月", income: 152400, expense: 139600 },
];

<BalanceChart
  title="本季收支"
  data={data}
  currency="NT$"
  onActiveIndexChange={(i) => console.log(i === null ? "清除" : data[i].label)}
/>

Props

PropTypeDefaultDescription
dataBalanceDatum[]—Income and expenses per period, in time order from left to right
heightnumber280Plot height (px); width always fills the container
surface"dark" | "auto""dark"dark is always a dark panel; auto is white and follows the site's dark: mode
showNetbooleantrueShow the net dots and line
negativeTone"rose" | "neutral""rose"Expense bar tone: rose or neutral grey
currencystring"NT$"Currency prefix before amounts; ignored when formatValue is provided
formatValue(value: number) => string—Amount formatter, called with the signed value; defaults to currency plus a grouped integer
activeIndexnumber | null—Controlled active period index; managed internally when omitted
defaultActiveIndexnumber | nullnullInitial active period in uncontrolled mode
onActiveIndexChange(index: number | null) => void—Called when hover, touch or the arrow keys change the period; null when cleared with Esc
barGapnumber0.28Fraction of each period column left empty (0–0.9)
titlestring"Balance" (Chinese build: "收支平衡")Header title text
ariaLabelstring"Income and expense balance chart" (Chinese build: "收支平衡長條圖")Accessible name of the chart, also the sr-only table caption
classNamestring—Passed through to the outermost container

BalanceDatum

FieldTypeDefaultDescription
labelstring—Period label, shown on the X axis and in the tooltip
incomenumber—Income amount; negative values are treated as 0
expensenumber—Expense amount, passed as a positive number and drawn below the zero axis; negative values are treated as 0

CSS variables

VariableDefaultDescription
--wb-balance-chart-positive#2dd4bf (#0d9488 on auto light)Income bar colour
--wb-balance-chart-negative#fb7185 (#f43f5e on auto light)Expense bar colour with negativeTone="rose", also used for negative net figures
--wb-balance-chart-accent#2dd4bf (#0d9488 on auto light)Colour of positive net figures
--wb-balance-chart-duration700msGrowth duration of each bar
--wb-balance-chart-stagger50msDelay between neighbouring periods starting to grow

How it works

  • Symmetric domain: the largest income or expense across all periods is rounded up to a multiple of 1, 2, 2.5, 5 or 10, with two grid segments on each side; tick values use the compact notation of Intl.NumberFormat (for example "200K").
  • Bars grow out of the zero axis with scaleY (income from its bottom edge, expenses from their top edge) on cubic-bezier(0.22,1,0.36,1); the net line draws in over 600ms via pathLength once every bar has landed, and the net dots appear along it in order.
  • The entrance fires once through useInView when the chart enters the viewport (margin -10% 0px); width is measured with ResizeObserver, using a 560px fallback during SSR and snapping to the container after mount.
  • The active column's band and the tooltip slide between columns on a spring (stiffness 400, damping 32); the tooltip sits on the far side of the column and is clamped inside the container, so it never covers the bars or overflows.
  • When columns are too narrow for every X label, labels are thinned out automatically; the active period is always labelled and bolded.

Accessibility

  • The plot is a focusable role="group": ← → switch periods, Home / End jump to the ends, Esc clears; a focus-visible ring shows while focused
  • The svg is role="img" with an aria-label summarising the period count, total net and the highest / lowest periods
  • An sr-only <table> carries the data (period, income, expenses, net) and is linked to the plot through aria-describedby
  • Switching periods announces that period's income, expenses and net through an sr-only aria-live="polite" region; the visual tooltip is hidden from assistive tech to avoid duplication
  • When the user's system has reduce motion turned on, bar growth and the line draw jump straight to their end state, the band and tooltip stop sliding and the net figure no longer tweens; hover and keyboard readouts still work