Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Alert volume

New

A security card of alerts triaged per day or week, stacked by source, with the escalated few drawn below the line.

alert-volume/alert-volume.tsx
Live · interactive

Alert volume

Triaged by Halyard

Date range
Source
Group by
Verdict

43,969 alerts triaged in 30 days

99.4% closed without a human

  • Identity
  • Endpoint
  • Cloud
  • Email
  • Network
  • SaaS
  • Escalated
Auto-closed
43,719
Escalated
250
Median triage
38 s
Analyst time saved
1,180 h

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

  • A count that comes in by day or week and splits into a few named sources, where the mix matters as much as the total.
  • A small share that needs its own scale beside the whole: escalations under the line, read on their own axis.
  • An overview card a team opens daily, where a handful of filters (period, source, grouping, verdict) cover most questions.
  • Up to six or seven series; past that the stacks turn to noise and the legend wraps for lines.

When not to use

  • A trend over many months where only the line matters. Use a line or area chart, which reads change better than bars.
  • Comparing sources against each other: stacked segments share no baseline. Use grouped bars or small multiples.
  • One figure with no history behind it. Use a stat tile, or the four figures at the bottom on their own.
  • Data whose readers must act on each row (a queue of alerts to triage). Use a table with filters, and link the card to it.
  • Many more than 60 bars: they thin out to slivers. Sum them into weeks, or months, first.

You can add this component with the shadcn CLI or by hand:

Into components/alert-volume of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/alert-volume.json

Either way it needs React 19.2 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

security-overview.tsx
import { AlertVolume } from './alert-volume';export function SecurityOverview() {  return <AlertVolume product="Halyard" defaultGranularity="week" autoplay={false} />;}

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.

The card a security team opens to see how many alerts were triaged, and by what. Switch between days and weeks, filter by date range, source and verdict, or drop the grouping; every bar stacks its alerts by source above the baseline and its escalations below it on a scale of their own. Hover a bar and a ring closes round it while a frosted note lists its top three sources, and four figures under the chart follow the filters.

  • The data is 26 invented weeks: the last 30 days follow a fixed outline (quiet weekends, a phishing spike on 2 October with 900 email alerts), the weeks before them come from a seeded generator, so every load draws the same chart. Weeks are summed Monday to Sunday from the days.
  • The chart is one SVG drawn at the width it is given, read by a ResizeObserver, so its type and spacing keep their size from a phone to a desktop and only the bars’ width (65% of each slot, 24 px at most) follows the space.
  • Both scales fill their room: the tallest bar is 172 px and the most escalations 40 px. Grid lines fall on a round step that leaves two or three under the tallest bar (1k, 2k; 5k, 10k), and one round label sits under the baseline.
  • Stacked segments are drawn 2 px short of their share, which leaves the gaps between them without moving the top of the bar. Escalations are drawn at 87% opacity, and at full strength on the hovered bar.
  • The pointer picks the bar nearest to it; the ring stands 3 px off the bar’s sides and 4 px over its top, down to 3 px under the bottom of its escalations. The note goes on the roomier side with a dotted line to the ring, or floats over the plot when neither side has room, and blurs what is behind it.
  • A change of view (switch, range, source, grouping, verdict) gives the bars a new key, so they grow again from the baseline, 9 ms apart: transforms on their own groups, nothing laid out.
  • The Day / Week switch keeps a range of its own for each (30 days, 12 weeks). The filters are listboxes the card holds open or shut, which lets the demo open one and move through it the way a reader would.
  • While on screen and untouched, a demo pointer hovers the spike and the days around it, switches to weeks, filters to email and back, and returns to days. Any input of the reader’s stops it, and leaves what the reader is hovering in place.
  • Every colour is a light-dark() token on the root: a near-black card on a dim stage at night, a white one on pale grey by day, with each source a step deeper so it holds on white.
PropTypeDefaultDescription
productstring'Halyard'The product that triages the alerts, named under the title.
defaultGranularity'day' | 'week''day'One bar per day (the last 30 days) or per week (the last 12 weeks), at first.
defaultSource'all' | 'Identity' | 'Endpoint' | 'Cloud' | 'Email' | 'Network' | 'SaaS''all'Only one source’s alerts, or all of them, at first.
defaultGroupBy'source' | 'none''source'Bars stacked by source, or in one colour, at first.
defaultVerdict'all' | 'auto' | 'escalated''all'All alerts (escalations drawn below the line), only those closed without a person, or only the escalated ones, at first.
defaultActivenumber | nullnullThe bar shown hovered at first, by its index from the left, until the pointer or the keys move on.
defaultRangestring—The date range at first, by its id, one of the granularity’s: '30d', '14d' or '60d' by day, '12w' or '26w' by week (by default the first). Turns the demo off.
defaultMenu'range' | 'source' | 'group' | 'verdict' | nullnullA filter’s menu open at first, its picked option lit, until the reader clicks. Turns the demo off.
autoplaybooleantrueWhile on screen and untouched, a demo pointer hovers bars, switches to weeks and filters by a source.
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.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

alert-volume.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useMemo,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import {  ANALYST_SECONDS,  MEDIAN_TRIAGE,  MEDIAN_TRIAGE_ALL,  RANGES,  SOURCES,  buckets,  longDate,
alert-volume.module.css
/* Alert volume (alert-volume.tsx): a near-black card on a dim stage, or by day a white one on a   pale grey stage. The card is 600 px at most and lays itself out from its own width (a   container query): filters and figures go two by two on a phone.   Colours are the tokens below, light-dark(light, dark) where the two differ: 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).   Memory. The bars grow by transforms on their own groups, the demo pointer moves by transforms,   and the note over the chart is the only thing that blurs what is behind it. */.root {  /* The stage around the card, its frame, and the card. */  --stage: #ececec;  --stage-2: #f6f6f6;  --frame: #e2e2e2;  --card: #fff;  /* Lines between the card's bands, and the boxes in it (icon, switch, menus). */  --line: #ececec;  --box: #f6f6f6;  --box-line: #e6e6e6;  --box-on: #fff;  --box-on-line: #e0e0e0;  /* Words: the strongest, the softer, the muted. */
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The alerts behind the card: 26 weeks of invented days, Monday 13 April to Sunday 11 October, * each with its alerts by source and how many of them were escalated to a person. The last 30 * days follow a fixed outline (quiet weekends, a spike of phishing on 2 October); the weeks * before them come from a seeded generator, so every load draws the same chart. */export const SOURCES = ['Identity', 'Endpoint', 'Cloud', 'Email', 'Network', 'SaaS'] as const;export type Source = (typeof SOURCES)[number];export type Day = {  /** Midnight UTC of the day, in ms. */  time: number;  /** Alerts per source, in the order of SOURCES; escalated ones included. */  alerts: number[];  /** Of those, how many went to a person, per source. */  escalated: number[];};const DAY = 86_400_000;/** The last day drawn: Sunday 11 October 2026. */const LAST = Date.UTC(2026, 9, 11);
filter-menu.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, type KeyboardEvent } from 'react';import styles from './alert-volume.module.css';/* * One of the card's filters: a small label over its value, which opens a listbox under it. The * card owns whether it is open and which option is lit, so its demo can open a menu and move * through it like a reader would; the menu itself only reports what the reader does. */export type Option = {  value: string;  label: string;  /** Muted words at the end of the row (a date range's dates). */  hint?: string;  /** A colour swatch before the label (a source's colour). */  swatch?: string;};export function FilterMenu({  name,
demo-pointer.tsx
'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, or * glide the pointer somewhere. 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. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the eye of the * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
use-autoplay.ts
'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 card is a section named by its title. Day / Week is a radio group (arrow keys move between them); each filter is a button that opens a listbox, worked with the arrow keys, Home, End, Enter and Escape, and the focus returns to the button.
  • The chart is a focusable group with a name that gives the total and the period. The arrow keys, Home and End move from bar to bar, showing the ring and the note, and a polite live region reads out each bar’s date, total, top sources and escalations.
  • Changing the view announces the new total and dates. The legend is a list, the four figures a description list; the bars, the note and the demo pointer are hidden from screen readers.
  • Under reduced motion the bars appear without growing, menus open without easing and the demo never plays.