Charts

Axis Brush

A time-axis brush: drag a window over a thumbnail sparkline and it emits that window for every other chart on the page to filter by.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ types --- */

export interface AxisBrushPoint {
  /** Epoch milliseconds. */
  t: number
  /** Any measure — it only shapes the context sparkline; the brush never reports it. */
  v: number
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-axis-brush.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ChartAxisBrush" component (no extra
libraries; pointer events and one hand-drawn SVG sparkline).

Contract
- forwardRef div extending HTMLAttributes minus onChange/defaultValue.
- Controlled only: domain: [from, to] in epoch ms, value: {start, end} | null,
  onChange(value). null is a real value meaning "no filter" — the component
  never owns the window and never invents one on mount.
- onCommit?(value) fires once per settled gesture: pointer release, key press,
  click, clear. Expensive work (refetch, URL sync) goes here.
- series?: {t, v}[] draws a context sparkline behind the rail. Unsorted input is
  fine, non-finite points are dropped, fewer than two points draws nothing.
- minSpan (ms), step (ms snap grid, 0 = continuous), defaultSpan (window made by
  a click or by the keyboard affordance, default a quarter of the domain),
  height (px, clamped 24–400), ticks (upper bound on axis labels),
  formatTick(time), disabled, label, labels (accessible names).
- Every numeric prop is clamped: an inverted or zero-width domain renders the
  rail inert instead of dividing by zero, and a degenerate or reversed incoming
  value is repaired into a real window before it is drawn.

Behavior
- Four gestures on one rail, routed by what is under the press (a data-attribute
  lookup with closest()): the two handles resize one edge, the window body pans,
  empty rail paints a new window from the press instant, and a double click
  clears back to null.
- A press that never travelled is a click: on empty rail it recentres the window
  on that instant keeping its width; on a handle or on the window it does
  nothing, because grabbing something and letting go must not move it.
- Drag mechanics, in this exact order, each one fixing a real failure:
  * pointer capture is taken past a ~10px slop, never on pointerdown — capturing
    early retargets the click away from whatever was pressed;
  * until capture is taken there is nothing keeping events on the rail, so the
    move/up/cancel listeners live on window for the duration of the gesture (and
    are removed on end and on unmount). An element-only listener loses any flick
    whose first move lands outside a 56px-tall rail;
  * every move is a delta from the press point applied to the value at press,
    never the instant under the pointer — an absolute map makes a handle grabbed
    off-centre jump on the first move;
  * a move with event.buttons === 0 ends the drag: a release outside the window
    never delivers pointerup, and browsers reuse pointerIds, so a stale drag
    would follow an unpressed hover around.
- Values funnel through one path: snap to the step grid first, then clamp. That
  order is what makes the floor exact rather than off-grid. The floor is the
  largest of minSpan, step and 24px of measured rail — a window thinner than its
  own handles could never be grabbed back. A pan clamps at the domain edge
  carrying its width through, so panning never squashes the window.
- Keyboard: three sliders (start handle, window body, end handle). Arrows nudge
  by step, or 1% of the domain when the axis is continuous; Shift multiplies by
  ten; Home/End go to that control's own bound; Delete/Backspace on the window
  clears it (parity with the double click). Keys preventDefault and
  stopPropagation so a host dashboard's own arrow bindings do not fire twice.
- With nothing selected the rail owns no focusable node, so it renders a button
  covering it whose accessible name says what it does. Pointer presses are
  served by the rail; only a keyboard activation (event.detail === 0) goes
  through the button, and focus is handed to the window it just created, because
  that button unmounts the moment a window exists.
- The default tick formatter is UTC on purpose: a zone-dependent default renders
  one label on the server and another in the browser.

Rendering & styling
- Semantic tokens only: rail bg-muted, sparkline var(--chart-1) (fill at 0.2,
  stroke with vectorEffect="non-scaling-stroke" so the stretch does not thicken
  it), the excluded parts of the rail faded with bg-background/70, the window
  bg-primary/10 with ring-primary/40, handles bg-primary with ring-background so
  they never dissolve into the selection, all text foreground /
  muted-foreground — chart tokens are for graphics, never for type.
- The rail's own paint sits in an inner rounded overflow-hidden box; the handles
  are siblings outside it, because they overhang the rail by half their width.
  The component reserves that overhang as horizontal padding, so nothing it
  draws can widen the page.
- Handles are 24px wide for a fine pointer and 44px under pointer-coarse, with
  the gutter widening to match.
- Axis labels are thinned to the measured rail width (ResizeObserver, one label
  per ~88px) and never rotated; the first and last are anchored to the rail's
  edges instead of centred so no label can hang outside the control.
- Position transitions are disabled while dragging (the window must track the
  finger exactly) and carry motion-reduce:transition-none, so reduced motion
  snaps instantly while the drag still follows the pointer.
- Accessibility: role="group" on the root; three role="slider" children with
  aria-valuemin/max reflecting their own live bounds, aria-valuenow and a
  human aria-valuetext; aria-keyshortcuts="Delete" on the window; the sparkline
  is aria-hidden with a one-sentence sr-only summary beside it. touch-none on
  the rail so a touch drag never scrolls the page.

Customization levers
- Density: height is the one knob for the rail; the handle width (w-6), the grip
  (w-1) and the tick strip (h-4) are the three that follow it.
- Emphasis: swap the outside scrim (bg-background/70) for a stronger fade, or
  drop the window's bg-primary/10 and keep only the ring if the sparkline shape
  matters more than the selection.
- Colour: var(--chart-1) on the sparkline should match the series the linked
  chart plots; move both to --chart-2/3 together, never one of them.
- formatTick drives the axis labels and the readout at once — pass a zoned
  Intl.DateTimeFormat for local time, or an hour/minute format for intraday
  domains. Keep it pure.
- Guard rails: minSpan expresses a product rule ("reports are weekly"), step
  expresses the data's own granularity ("one point per day"); set both and the
  edges land on the grid and never come closer than the rule allows.
- Readout: the range and duration line is a plain flex row above the rail —
  move it below, replace the duration with a row count from your own data, or
  drop it entirely if the linked chart already states the window.
- Axis: for a non-time domain, pass formatTick as a number formatter — every
  value in the component is just a number, only the default formatter is dates.

Concepts

  • Brush as a controller, not a chart — the component renders a thumbnail of the axis but reports only {start, end}; the charts that react to it stay ordinary controlled charts. Overview-and-detail lives in the consumer's state, which is why one brush can drive five panels without any of them knowing about each other.
  • Null as a first-class value — "no window" is not an empty object or the full domain, it is null. Downstream code can tell "the user cleared the filter" from "the user selected everything", and the brush renders a different affordance for it instead of a zero-width selection.
  • Deferred pointer capture — capture is claimed only after the press has travelled past a slop threshold. Claiming it on pointerdown retargets the click to the capturing element, which quietly breaks every plain press inside the control.
  • Window listeners for the life of a gesture — before capture exists, moves go to whatever is under the pointer, so a flick that leaves a 56px rail in one event would never reach an element handler. The listeners attach on press, detach on release, and are torn down on unmount so a drag can never outlive the component.
  • Delta dragging — a move applies pointer − press to the value captured at press, never the instant under the pointer. Grabbing a 24px handle 9px off-centre and nudging it 4px moves the edge by 4px worth of time, not 13.
  • Minimum window measured in pixels — the floor is the larger of the product's own minSpan, the step grid, and 24px of measured rail. A time-based floor alone lets a wide domain collapse the window into something narrower than its own handles, which the pointer can then never grab again.
  • Change versus commitonChange streams every frame so linked charts follow the drag live; onCommit fires once per settled gesture, which is where a refetch or a URL write belongs.

On This Page