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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-axis-brush.jsonPrompt
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
pointerdownretargets 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 − pressto 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, thestepgrid, 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 commit —
onChangestreams every frame so linked charts follow the drag live;onCommitfires once per settled gesture, which is where a refetch or a URL write belongs.
Likert Diverging
A four-state diverging Likert chart — one row per question with the negative options stacked leftward and the positive rightward around a shared centre, a neutral band that either straddles the axis or steps out of the bar, a switchable percentage base and net-score sorting.
Network Force
A four-state force-directed relationship graph in plain SVG — a hand-written repulsion, spring and gravity simulation that converges and then stops, nodes you can drag and anchor, colour by group or by degree, and hover to light a node's neighbours and fade the rest.