Metric picker
NewA dashboard’s metric picker whose submenus hold while the pointer cuts across other sources, with a prediction cone whose apex steps back as it nears them.
metric-picker/metric-picker.tsxThe 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
- Picking what a chart plots when the choices come in two levels: a source of data, then one of its metrics.
- Six to a few dozen sources in a handful of groups, each with its own short list of metrics.
- A dashboard toolbar, where the pick has to read as one phrase (“Requests Count”) beside the other tools.
- Pointer users who cross the list on a diagonal to reach a metric: the cone keeps the submenu they aimed at open.
- Keyboard users too: one search field filters both columns, and the arrow keys walk the sources and the metrics.
When not to use
- A flat list of a few metrics with no source behind them. Use a select or a segmented control.
- Several metrics plotted at once. Use a multi-select with checkboxes, or a legend with toggles.
- Sources with hundreds of metrics each: the submenu doesn’t scroll. Use a search-first command palette.
- Touch-only screens, where nothing hovers: the cone does nothing there. Use a two-step sheet (source, then metric).
- Navigation between pages. Use a menu or links; this picks a value and closes.
You can add this component with the shadcn CLI or by hand:
Into components/metric-picker of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/metric-picker.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
import { MetricPicker } from './metric-picker';import type { MetricValue } from './metrics';export function UsagePanel({ onMetric }: { onMetric: (value: MetricValue) => void }) { return ( <MetricPicker defaultValue={{ source: 'function-calls', metric: 'duration' }} defaultShowCone={false} onChange={onMetric} 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 metric control of an analytics dashboard: sources of data in groups on the left, the metrics of the lit one on the right. A pointer heading diagonally for a metric crosses other sources on the way. A cone from just behind the pointer to the metrics’ edge tells the picker it is on its way, so the submenu holds. Every box is measured once, when the popover opens.
- When the popover opens, and again when a search changes its rows, it reads its boxes once: the list’s rows, where the metrics begin and the popover’s place on screen. From then on, following the pointer is arithmetic on those numbers plus the list’s scroll offset, kept from its scroll events, with no read of the page.
- Each move tests the new point against the cone drawn at the last one: a triangle from just behind the pointer to the top of the first metric and the bottom of the column. Inside it and still heading right, the pointer leaves the sources it crosses alone; outside it, or heading back, the source under the pointer takes over at once.
- The apex steps back behind the pointer by a buffer that grows, eased in, as the pointer nears the metrics: nothing at the far side of the list, most of a row’s height at the edge, where the cone would otherwise be a sliver a shaky hand slips out of.
- Resting 320 ms on another source inside the cone hands the submenu to it, so a reader who changes their mind halfway is not left with the wrong metrics.
- The cone is a plain box over the popover, cut to the triangle by a clip-path set on every move, and React renders only when the lit source or metric changes. Unticking Show cone hides the drawing; the behaviour stays on.
- Every colour is a light-dark() token on the root, the chart’s series and the cone included, so it follows the page’s color-scheme, or the colorScheme prop, with no colour read in script.
| Prop | Type | Default | Description |
|---|---|---|---|
sources | SourceGroup[] | SOURCES | The sources of data in labelled groups. Each source has an id, a label, an icon and the metrics it can chart. |
label | string | 'Metric' | The control’s label, above the toolbar. |
defaultValue | MetricValue | { source: 'requests', metric: 'count' } | The metric picked at first. |
onChange | (value: MetricValue) => void | — | Called when the reader picks a metric. |
defaultShowCone | boolean | true | Whether the prediction cone is drawn at first. It works either way. |
defaultState | MetricPickerState | — | Opens in a state, with the demo off: the popover open, a search typed (query), the lit source and metric (active, sub, column), and a pointer resting on a source with its cone drawn (pointer). |
autoplay | boolean | true | While on screen and untouched, a demo pointer opens the picker, travels diagonally to the metrics of two sources across the others, and picks one. |
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 dashboard 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, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent,} from 'react';import { Chart, seriesOf } from './chart';import { coneAt, inTriangle, locate, type Layout, type Point } from './cone';import { DemoPointer, runDemo } from './demo-pointer';import { Calendar, ChevronRight, Plus, Selector, SourceIcon } from './icons';import { SOURCES, filterSources, type MetricValue, type Source, type SourceGroup } from './metrics';import { useAutoplay } from './use-autoplay';import styles from './metric-picker.module.css';/* * Metric picker: the metric control of an analytics dashboard. The trigger opens a popover with a * search field, the sources of data in groups, and at their right the metrics of the lit source./* Metric picker (metric-picker.tsx): a dashboard stage, the label and the toolbar on top, a faint line chart under them, and the picker's popover over the chart. Sizes are in em of the stage's text, 14px wherever the popover fits at full width, down to 11px on a phone. The stage is one layout boundary of a set height: what changes while the demo plays (the lit rows, the submenu's rows, the trigger's words) lays out the stage alone. The cone is a plain box cut by a clip-path, and the pointer moves on a small layer of its own. 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). */.root { /* The stage, which the tools share, its edge, and the popover raised over it (and its key). */ --surface: #fff; --edge: rgb(0 0 0 / 0.06); --surface-2: #fff; /* The trigger hovered or open, and the lit row in the popover. */ --hover: #fafafa; --lit: #f0f0f0; /* The words: the picks and values; the tools' words and icons; the quiet ones (the axis, the groups, the placeholder); the label; the rows' icons and the key's word. */ --ink: #171717; --ink-soft: #666;/* * The prediction cone, in the popover's own px. While the pointer is in the list, the cone runs * from just behind it to the submenu's near edge: the top of its first row and the bottom of the * column. A pointer whose next position is still inside the cone, and still heading right, is on * its way to the submenu, so the rows it crosses are left alone. * * Near the submenu the cone is thin around the pointer, and a hand that drifts a pixel up or down * would leave it. So its apex steps back behind the pointer, by a buffer that grows as the * pointer closes in: nothing at the far side of the list, most of a row's height at the edge. * * Every box comes from the layout measured once when the popover opens (or when a search changes * its rows): following the pointer is arithmetic on those numbers, never a read of the page. */export type Point = { x: number; y: number };/** The popover's boxes, in its own px, as measured when it opened. */export type Layout = { /** The list's column: its left edge, and where the rows start. */ listLeft: number; listTop: number; /** The submenu's column: its near edge, the top of its first row, its right edge. */ subLeft: number; subTop: number;/* * What the picker offers: sources of data in groups, and the metrics each source can chart. A * source opens its metrics at the right of the list; the trigger names the pick as "<source> * <metric>", say "Requests Count". */export type IconName = 'globe' | 'image' | 'revalidate' | 'function' | 'gateway' | 'outbound' | 'blocked' | 'firewall';export type Metric = { /** Unique within its source. */ id: string; label: string;};export type Source = { /** Unique across the picker. */ id: string; label: string; icon: IconName; metrics: Metric[];};export type SourceGroup = {import { Clock } from './icons';import styles from './metric-picker.module.css';/* * The faint line chart under the picker. It is scenery, so it is hidden from screen readers. Each * metric draws one of a few series, all drawn up front from a fixed seed (the server and the page * draw the same); picking a metric swaps the series on show by its visibility, and nothing inside * an SVG changes. */const SERIES = 5;const POINTS = 72;/** A small seeded generator: the same numbers on every run. */function seeded(seed: number) { let state = seed; return () => { state = (state * 1664525 + 1013904223) % 4294967296; return state / 4294967296; };}/** A jagged line in the upper part of a 100 × 100 box. */function series(seed: number) {import type { ReactNode } from 'react';import type { IconName } from './metrics';/* * The picker's line icons, drawn on a 16 px grid with the text's colour. They are decorative: * every one sits next to the words it stands for. */function Icon({ className, children }: { className?: string; children: ReactNode }) { return ( <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" > {children} </svg> );}'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. The picker follows the pointer on every frame, * as it follows a mouse, so each glide is a requestAnimationFrame loop that places the arrow and * hands the picker its position on screen; nothing runs between steps. 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 arrow is an HTML box around a still drawing that moves by its transform: a change of style * on an SVG element would lay that SVG out again on every frame. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** The point at a share of `target`'s width and height, in the stage's px. */ pointOn: (target: Element | null | undefined, x: number, y: number) => Point | null; /** * Glides the pointer to `to` (in the stage's px) over `ms`, along a path bent sideways by'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 trigger names its pick (“Metric Requests Count”) and says whether the popover is open. The search field inside is a combobox that owns two listboxes, the sources in labelled groups and the lit source’s metrics, and aria-activedescendant follows the lit option.
- ↑ and ↓ move through the sources or the metrics, and the list scrolls to the lit row; → goes into the metrics once the caret is at the end of the text, and ← comes back. Enter picks, Escape closes and returns the focus to the trigger, typing filters both columns, and ↓ on the trigger opens it.
- A pick is announced (“Metric: Gateway Calls Duration”). The cone, the chart and the toolbar’s other controls are decorative and hidden from screen readers. On touch, a tap lights a source and a tap on a metric picks it.
- Under reduced motion the popover appears at once and the demo does not play.