Glow bars
AdvancedNewA cash-flow chart. The month under the pointer lights up in violet and blue, and a tooltip glides along with it, rolling its figures.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Two money series side by side over a few periods, money in against money out, where the reader compares one period at a time.
- Between 4 and 12 periods: months, quarters or seasons, each with a short name under its column.
- A dashboard card where the exact figures matter on demand: the tooltip gives both, to the cent, for the lit period.
- A hero chart worth a moment of delight: the lit pair glides from column to column and its digits roll.
When not to use
- More than two series, or series that are not money. Use a grouped bar chart with its own legend and axis format.
- A long time series (years of days or weeks). Use a line or area chart that can zoom and pan.
- When every value must be readable at once, as in a report or a print. Use a table, or bars with their labels on them.
- Totals that are negative, or a net figure on its own. The bars start at $0; show the net in the subtitle, updated through
onMonthChange. - Dense screens with several charts: one glowing card draws the eye, several compete for it.
You can add this component with the shadcn CLI or by hand:
Into components/glow-bars of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/glow-bars.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
'use client';import { useState } from 'react';import { GlowBars } from './glow-bars';import { MONTHS } from './months';export function CashFlowCard() { const [month, setMonth] = useState(2); return ( <GlowBars autoplay={false} months={MONTHS} max={6000} defaultMonth={month} onMonthChange={setMonth} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Below are variations of this component, each live, with its code.
Quarters
A shop’s year in four quarters: wider bars on a $12k axis, the series named Sales and Costs, and the fourth quarter lit.
Full year
Twelve months of a lake cabin’s rentals, with slimmer bars over the months’ initials and August, the busiest, lit at first.
Over budget
Six months on a $3k axis in which spending passes earnings twice; September is lit, so the tooltip sits on the taller blue bar.
Net per month
A cash-flow card whose subtitle gives the lit month’s net, incomes less outcomes: onMonthChange keeps it current.
A monthly chart of incomes and outcomes, muted at rest. Point at a month, or move with the arrow keys, and its two bars light up: incomes in a violet-pink glow and outcomes in blue, both with a fine grain and a sheen running up them. The light and the tooltip glide from month to month, and the tooltip’s figures roll to their new values.
- The lit pair is a single column of two glowing bars that a transform moves to the active month. Each bar’s height is a clip-path inset and its gradient a background-size, both transitioned, so the light stretches as it glides and no box ever changes size.
- The tooltip travels inside that column, placed by three nested transforms set from CSS variables: one lifts it to the taller bar, one flips it to the other side of the pair near the right edge, and one gives it its own offset. Each digit of its figures is a reel of 0 to 9 that slides to its value.
- The first time the chart comes into view, the bars grow in month after month, with the Web Animations API on clip-path. Once a glide settles, a soft white band runs up the two lit bars. The frosted look comes from a tiled noise texture and an inset rim.
- With a mouse or a pen, the month under the pointer lights up; a touch picks a month while it is down, and a press or a key also announces the month. The month is read from the plot’s box, so it still lands right when the chart is drawn scaled down.
- The demo pointer sweeps from January to July and back to March. The sweep is an animation the compositor plays, and the lit month is worked out from how far along it is rather than from the page’s geometry, so the demo never forces a layout.
- Every colour is a light-dark() token, so the card follows the page’s color-scheme, or the colorScheme prop: white by day, near-black at night, on the same light grid. On white the lit bars are a shade deeper and cast a soft light in their own colour, a drop shadow on a wrapper round each bar, so it follows the bar as it stretches and grows in.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'Cash Flow Trends' | The card’s title. |
subtitle | string | 'Monthly income vs expenses comparison' | The line under the title. |
months | CashMonth[] | MONTHS | The months in order, each with a short name, a full name, and incomes and outcomes in dollars. |
max | number | 6000 | The top of the y axis in dollars; there is a gridline at every sixth of it. |
series | { income: string; outcome: string } | { income: 'Incomes', outcome: 'Outcomes' } | The names of the two series, money in and money out, as the legend shows them and screen readers hear them. |
defaultMonth | number | 2 | The month lit at first, as its index in months. |
onMonthChange | (index: number) => void | — | Called with the index of each month the reader lights. |
onMenu | () => void | — | Called when the ⋮ button is pressed. |
autoplay | boolean | true | While on screen and untouched, a demo pointer sweeps across the months. |
growIn | boolean | true | The bars grow in, month after month, the first time the chart comes into view; false draws them at their values at once. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { MONTHS, type CashMonth } from './months';import { useAutoplay } from './use-autoplay';import styles from './glow-bars.module.css';/* * Glow bars: a cash-flow card whose bars light up under the pointer. * * - Each month is a pair of rounded bars, incomes and outcomes, muted at rest. The month under the * pointer (or picked with the arrow keys) glows: incomes in violet and pink, outcomes in blue/* Glow bars (glow-bars.tsx): a white card on a light square grid, or at night a dark one. The card is laid out at fixed places, in px, for a width of 640; narrower, the plot's columns share what is left. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The stage's grid is the same in both. The lit bars' gradients, rims and light are pairs in their own rules, further down. Memory. The stage is the one layout boundary, of a fixed height. What moves while the pointer sweeps stays on small layers of a fixed size: the lit column (its glow repaints inside it as the bars stretch), the tooltip and the reels of its digits. Nothing that moves changes size. */.stage { /* The stage, and the card on it. */ --surface: #edecef; --surface-2: #fff; /* The title; the subtitle; the axis' labels; the months; the legend. */ --ink: #17181c; --ink-muted: #686b73; --ink-faint: #74767d; --month-ink: #71737a; --legend-ink: #6b6d74; /* The gridlines and the axis. *//** One month of the chart: what came in and what went out. */export type CashMonth = { /** The short name under the axis: "Mar". */ short: string; /** The month in full, in the tooltip and for screen readers: "March 2025". */ name: string; /** Money in, in dollars. */ income: number; /** Money out, in dollars. */ outcome: number;};/** Sample months, January to July. */export const MONTHS: CashMonth[] = [ { short: 'Jan', name: 'January 2025', income: 2698.4, outcome: 2184.75 }, { short: 'Feb', name: 'February 2025', income: 5072.1, outcome: 3106.3 }, { short: 'Mar', name: 'March 2025', income: 4234.2, outcome: 2630 }, { short: 'Apr', name: 'April 2025', income: 2061.5, outcome: 1214.8 }, { short: 'May', name: 'May 2025', income: 2468.9, outcome: 1532.4 }, { short: 'Jun', name: 'June 2025', income: 3248.6, outcome: 1947.2 }, { short: 'Jul', name: 'July 2025', income: 4318.7, outcome: 2652.9 },];'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide * the pointer to a point, or sweep it slowly along a line while the chart follows it. Stopping the * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise * rejects, which ends the script right there, and the pointer fades out. * * The pointer moves by animations the compositor plays (a sweep too: the chart reads that * animation's clock every frame, as a share of the way, and never the page's geometry), and it is * an HTML box around a drawing that never changes, so moving it never makes the browser lay * anything out. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer to a point of the screen, in client px. */ moveTo: (point: Point) => Promise<void>; /**'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The chart is a focusable group with the role description “chart” and a short hint. The arrow keys, Home and End move between months, and each month’s two figures are read out in a polite live region; the demo’s own moves are not announced.
- The ⋮ is a labelled button and the legend is a list. The gridlines, bars, axis labels and the tooltip drawing are hidden from screen readers.
- Focus rings are visible on the chart and on the button. Under reduced motion there is no grow-in, glide, rolling digits, sheen or demo.
- The server renders a complete chart with March lit, so it reads the same without JavaScript.