Time range pickerNew
"Last 15 minutes" for logs, metrics and traces: type 2h or yesterday 14:00–16:00, step by the window, pause live, or drag across a chart to zoom.
Used forLog viewersMetrics dashboardsIncidents and tracesAnalytics and audit logs
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/time-range-picker.jsonWhere it’s used
Log viewers
Show the last 15 minutes of logs and keep following new ones as they arrive.
Metrics dashboards
Switch every chart between the last hour, today and last week in one place.
Incidents and traces
Zoom into the exact minutes something went wrong by dragging across a chart.
Analytics and audit logs
Filter events with typed ranges like “yesterday 14:00–16:00”.
What it handles for you
A calendar date-range picker answers “which days”. Monitoring screens ask “which stretch of time, ending when”, and their ranges have to keep moving. Grafana and Datadog each built their own; this is yours.
- 01
Ranges that stay live
“Last 15 minutes” keeps following the clock; Pause freezes it where it is.
- 02
Steps by the window
‹ and › move back and forward by the range’s own length, and go live again on reaching now.
- 03
Type any range
2h, yesterday, since 9, 14:00–16:00 or 22:00–02:00; the line under the field says what it means before you press Enter.
- 04
Drag to zoom
Time brush turns a drag across any chart into the range under it.
- 05
Any time zone
Times are read and shown in the zone you pass, across clock changes.
- 06
Fits a toolbar
One compact plate that tightens itself on phones.
Overview
A date picker answers "which days", but a logs or metrics screen asks "which stretch of time, ending when": the last fifteen minutes, still moving; the hour before that; yesterday between two and four. This is one hairline plate: ‹ steps back by the window's own length (so "Last 1 hour" becomes the hour before it, fixed), › steps forward and turns live again once it reaches now, and Live breathes while the range follows the clock, Paused when it doesn't. Open it and the field is already focused: type 45m, yesterday, since 9 or 14:00–16:00 and the line under it says exactly what that means before you press Enter. Quick ranges have a highlight that glides with the pointer and a check that draws itself on the current one; ranges you typed come back as Recent. The range's words morph as it changes. Time brush comes with it: wrap any chart and a drag across it becomes the range under the band.
States
- live
- A relative range ("Last 1 hour") or one that runs until now ("Today so far") is live: an emerald dot breathes beside Live (2.4s). Pressing it freezes the range where it is; the dot shrinks into a ring and the label morphs to Paused. › is disabled while live.
- step
- ‹ and › move the window by its own length. The label morphs letter by letter ("Last 1 hour" → "Today, 13:20 – 14:20"). Stepping forward into now makes the range live again.
- open
- The panel unfolds from the plate's edge (a clip from the top, 380ms), 6px clear of it, and lines up with its right edge by default. It lives on the page's body, so no card clips it, and it follows the plate when the page scrolls.
- typing
- Each keystroke is read; the line under the field crossfades to what it means ("Yesterday, 14:00 – 16:00 · 2h") and a ↵ appears. Enter applies it. Text it can't read shakes the field and the line turns red with examples.
- quick
- A highlight glides between quick ranges with the pointer or the arrows (220ms); the current range carries a drawn check. Ranges you typed are kept as Recent chips.
- brush
- Time brush: pressing on a chart and dragging draws a band (a scaled fill and two hairline edges, transforms only) with its start, end and length on a tag above it. Release selects that span; the band fades as the chart redraws. Escape cancels.
Usage
import { TimeRangePicker, resolveTimeRange, type TimeRange } from "@/components/time-range-picker";const [range, setRange] = useState<TimeRange>({ type: "relative", duration: 15 * 60_000 });<TimeRangePicker value={range} onValueChange={setRange} />Works with
Querying logs for the range
Keep the range itself in state, not its start and end, so "Last 15 minutes" stays live. Resolve it whenever you fetch, and refetch on a timer while it's live. Wrap the chart in Time brush to zoom.
"use client";import { useEffect, useState } from "react";import { TimeBrush, TimeRangePicker, resolveTimeRange, type TimeRange } from "@/components/time-range-picker";export function Logs({ service }: { service: string }) { const [range, setRange] = useState<TimeRange>({ type: "relative", duration: 60 * 60_000 }); const [data, setData] = useState<LogBucket[]>([]); const live = range.type !== "absolute"; useEffect(() => { const load = async () => { const { start, end } = resolveTimeRange(range); const res = await fetch(`/api/logs?service=${service}&from=${start.toISOString()}&to=${end.toISOString()}`); setData(await res.json()); }; load(); if (!live) return; const timer = setInterval(load, 10_000); return () => clearInterval(timer); }, [range, live, service]); const { start, end } = resolveTimeRange(range); return ( <section> <TimeRangePicker label="Time range for logs" value={range} onValueChange={setRange} /> <TimeBrush start={start} end={end} onSelect={(r) => setRange({ type: "absolute", ...r })}> <VolumeChart buckets={data} /> </TimeBrush> </section> );}Props
| Prop | Type | Description |
|---|---|---|
| value / defaultValue | TimeRangedefault { type: "relative", duration: 3600000 } | { type: "relative", duration } (the last N ms, live), { type: "since", start } (until now, live) or { type: "absolute", start, end }. |
| onValueChange | (range, { start, end }) => void | The new range and the times it covers at that moment. Store the range: a relative one keeps moving. |
| presets | { label, range }[]default TIME_RANGE_PRESETS | Quick ranges. range may be a function of now (Today so far, Yesterday). |
| timeZone | string | Read and show wall-clock times in this IANA zone; the viewer's own by default. |
| hour12 | booleandefault false | Write times as 2pm. |
| align | "start" | "end"default "end" | Which edge of the plate the panel lines up with. |
| open / defaultOpen / onOpenChange | boolean / boolean / (open) => void | Control the panel. |
| label | stringdefault "Time range" | Accessible name of the plate. |
| TimeBrush | { start, end, onSelect, minSpan?, timeZone? } | Wrap a chart whose x axis runs from start to end; onSelect gets the span dragged across. Spans under minSpan (60s) are ignored. |
Accessibility and motion
- The plate is a labelled group of four buttons. The range button opens a dialog whose field is described by the live reading under it; ↓ moves into the quick ranges (a listbox driven by aria-activedescendant), ←/→ move across, Enter applies, Escape closes and returns focus. New ranges are announced in a status region.
- Typed ranges it reads: 15m, 2 hours, last 7 days, past week, today, yesterday, this week, last week, this month, last month, since 9, since monday, 14:00–16:00, 9am to 5pm, 22:00–02:00 (past midnight), yesterday 14:00-16:00, 16 oct 09:00 – 16 oct 11:30, oct 12 – oct 14, and ISO dates. Times without a date that haven't happened yet today mean yesterday's.
- parseTimeRange, formatTimeRange, resolveTimeRange and stepTimeRange live in time-range-parse.ts with no React, so a server can read the same ranges from a URL.
- Time brush moves only transforms while you drag and renders nothing; touch keeps vertical page scroll (touch-action: pan-y).
- Reduced motion: no unfolding, gliding, breathing or morphing; the words change in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Text morph.
Source
"use client";import * as React from "react";import { createPortal } from "react-dom";import { TextMorph } from "./text-morph";import { TimeBrush, type TimeBrushProps } from "./time-brush";import { DAY, HOUR, MINUTE, durationShort, formatTimeRange, isLive, parseTimeRange, resolveTimeRange, sameRange, stepTimeRange, type TimeRange,} from "./time-range-parse";/* ───────────────────────────────────────────────────────── * TIME RANGE PICKER: "Last 15 minutes ▾" for logs and metrics * * plate one hairline plate: ‹ earlier, the range, › later, * and Live. The range's words morph as it changes * step ‹ and › move the window by its own length; stepping * back from "Last 1 hour" fixes it to the hour before, * and stepping forward into now makes it live again * live the dot breathes while the range follows now; * Paused freezes it where it is * open the panel unfolds from the plate's edge with the * field focused: type 45m, yesterday, since 9 or * 14:00–16:00 and the line under it says what it * means before you press Enter * quick a highlight glides over the quick ranges; the * current one carries a drawn check; ranges you typed * come back as Recent * * Pair it with Time brush, which turns a drag across any chart * into a range. * ───────────────────────────────────────────────────────── */export type { TimeRange };export { TimeBrush, type TimeBrushProps };export { formatTimeRange, parseTimeRange, resolveTimeRange, stepTimeRange };export interface TimeRangePreset { label: string; /** A range, or one worked out from now (Today, Yesterday). */ range: TimeRange | ((now: Date, timeZone?: string) => TimeRange);}export interface TimeRangePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> { value?: TimeRange; defaultValue?: TimeRange; /** The range, and the start and end it covers right now. */ onValueChange?: (range: TimeRange, resolved: { start: Date; end: Date }) => void; presets?: TimeRangePreset[]; /** Read and show times in this IANA zone; the viewer's own by default. */ timeZone?: string; hour12?: boolean; /** Which edge of the plate the panel lines up with. */ align?: "start" | "end"; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Accessible name, e.g. "Time range for logs". */ label?: string; disabled?: boolean;}const rel = (duration: number): TimeRange => ({ type: "relative", duration });export const TIME_RANGE_PRESETS: TimeRangePreset[] = [ { label: "Last 5 minutes", range: rel(5 * MINUTE) }, { label: "Last 15 minutes", range: rel(15 * MINUTE) }, { label: "Last 1 hour", range: rel(HOUR) }, { label: "Last 4 hours", range: rel(4 * HOUR) }, { label: "Last 24 hours", range: rel(DAY) }, { label: "Last 7 days", range: rel(7 * DAY) }, { label: "Today so far", range: (now, tz) => parseTimeRange("today", { now, timeZone: tz })! }, { label: "Yesterday", range: (now, tz) => parseTimeRange("yesterday", { now, timeZone: tz })! },];const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring"];const HINT = "Try 45m, yesterday, since 9 or 14:00–16:00";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(reducedQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);// Phones get the tighter label and a plate without the clock or the word Live.const narrowQuery = "(max-width: 480px)";const subscribeNarrow = (onChange: () => void) => { const query = window.matchMedia(narrowQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useNarrow = () => React.useSyncExternalStore(subscribeNarrow, () => window.matchMedia(narrowQuery).matches, () => false);function zoneLabel(timeZone?: string) { try { const zone = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; const off = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "shortOffset" }).formatToParts(new Date()).find((p) => p.type === "timeZoneName")?.value.replace("GMT", "UTC"); return `${zone === "UTC" ? "UTC" : (zone.split("/").pop() ?? zone).replace(/_/g, " ")}${off && zone !== "UTC" ? ` · ${off}` : ""}`; } catch { return ""; }}function Arrow({ dir }: { dir: -1 | 1 }) { return ( <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5"> <path d={dir < 0 ? "M9.75 4.5 6.25 8l3.5 3.5" : "M6.25 4.5 9.75 8l-3.5 3.5"} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}/** A sentence that changes crossfades whole. */function Line({ text, reduced, className = "" }: { text: string; reduced: boolean; className?: string }) { const [shown, setShown] = React.useState({ text, key: 0 }); const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]); if (text !== shown.text) { if (shown.text && !reduced) setLeaving((l) => [...l.slice(-2), shown]); setShown({ text, key: shown.key + 1 }); } return ( <span className={`relative block ${className}`}> <span key={shown.key} className="block truncate" ref={(el) => { if (!el || reduced || shown.key === 0 || el.dataset.in) return; el.dataset.in = "1"; el.animate([{ opacity: 0, transform: "translateY(0.3em)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }], { duration: 340, delay: 40, easing: EASE, fill: "backwards" }); }} > {shown.text || " "} </span> {leaving.map((l) => ( <span key={l.key} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 block truncate" ref={(el) => { if (!el || el.dataset.out) return; el.dataset.out = "1"; el.animate([{ opacity: 1, filter: "blur(0px)" }, { opacity: 0, transform: "translateY(-0.3em)", filter: "blur(4px)" }], { duration: 160, easing: EASE, fill: "forwards" }).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key)); }} > {l.text} </span> ))} </span> );}type Phase = "closed" | "opening" | "open" | "closing";export function TimeRangePicker({ value, defaultValue = rel(HOUR), onValueChange, presets = TIME_RANGE_PRESETS, timeZone, hour12 = false, align = "end", open: openProp, defaultOpen = false, onOpenChange, label = "Time range", disabled = false, className = "", ...props}: TimeRangePickerProps) { const uid = React.useId(); const reduced = useReducedMotion(); const narrow = useNarrow(); const [own, setOwn] = React.useState(defaultValue); const range = value ?? own; const [now, setNow] = React.useState(() => new Date()); const [phase, setPhase] = React.useState<Phase>(() => ((openProp ?? defaultOpen) ? "opening" : "closed")); const [fit, setFit] = React.useState({ x: 0, y: 0, w: 320, up: false, theme: {} as Record<string, string> }); const [typed, setTyped] = React.useState(""); const [active, setActive] = React.useState(-1); const [mark, setMark] = React.useState({ x: 0, y: 0, w: 0, h: 0, on: false, slide: false }); const [recent, setRecent] = React.useState<TimeRange[]>([]); const [refused, setRefused] = React.useState(false); const [announce, setAnnounce] = React.useState(""); const anchorRef = React.useRef<HTMLDivElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null); const surfaceRef = React.useRef<HTMLDivElement>(null); const inputRef = React.useRef<HTMLInputElement>(null); const fieldRef = React.useRef<HTMLDivElement>(null); const presetRefs = React.useRef<(HTMLButtonElement | null)[]>([]); const timer = React.useRef(0); const mounted = phase !== "closed"; const expanded = phase === "open"; const live = isLive(range); // "Today" and "Last 1 hour" are about now: look at the clock once a minute. React.useEffect(() => { let t = 0; const tick = () => { setNow(new Date()); t = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50); }; t = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50); return () => window.clearTimeout(t); }, []); const words = formatTimeRange(range, { now, timeZone, hour12, compact: narrow }); const resolved = resolveTimeRange(range, now); const span = resolved.end.getTime() - resolved.start.getTime(); const presetRanges = React.useMemo(() => presets.map((p) => (typeof p.range === "function" ? p.range(now, timeZone) : p.range)), [presets, now, timeZone]); const currentPreset = presetRanges.findIndex((r) => sameRange(r, range)); const parsed = typed.trim() ? parseTimeRange(typed, { now, timeZone }) : null; const commit = (next: TimeRange, how?: "typed") => { const at = new Date(); if (value === undefined) setOwn(next); onValueChange?.(next, resolveTimeRange(next, at)); setAnnounce(formatTimeRange(next, { now: at, timeZone, hour12 })); if (how === "typed" && !presetRanges.some((r) => sameRange(r, next))) setRecent((list) => [next, ...list.filter((r) => !sameRange(r, next))].slice(0, 3)); }; /* ── Panel ── */ const place = React.useCallback(() => { const anchor = anchorRef.current; if (!anchor) return; const r = anchor.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const vh = window.innerHeight; const w = Math.min(320, vw - 16); const x = Math.min(Math.max(8, align === "end" ? r.right - w : r.left), vw - 8 - w); const up = vh - r.bottom < 380 && r.top > vh - r.bottom; const cs = getComputedStyle(anchor); const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily }; for (const t of TOKENS) { const v = cs.getPropertyValue(t); if (v) theme[t] = v; } setFit({ x, y: up ? vh - r.top + 6 : r.bottom + 6, w, up, theme }); }, [align]); const begin = () => { window.clearTimeout(timer.current); place(); setTyped(""); setRefused(false); setActive(-1); setMark((m) => ({ ...m, on: false, slide: false })); setPhase(reduced ? "open" : "opening"); }; const end = React.useCallback( (refocus = true) => { window.clearTimeout(timer.current); if (refocus) triggerRef.current?.focus({ preventScroll: true }); if (reduced) return setPhase("closed"); setPhase((p) => (p === "closed" ? p : "closing")); timer.current = window.setTimeout(() => setPhase("closed"), 240); }, [reduced], ); const request = (next: boolean, refocus = true) => { if (openProp === undefined) (next ? begin : () => end(refocus))(); onOpenChange?.(next); }; React.useEffect(() => { if (openProp === undefined) return; if (openProp && !mounted) begin(); if (!openProp && mounted) end(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openProp]); React.useLayoutEffect(() => { if (phase !== "opening") return; place(); inputRef.current?.focus({ preventScroll: true }); let inner = 0; const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase("open")))); return () => { cancelAnimationFrame(outer); cancelAnimationFrame(inner); }; }, [phase, place]); React.useEffect(() => () => window.clearTimeout(timer.current), []); React.useEffect(() => { if (!mounted) return; const inside = (n: Node) => Boolean(anchorRef.current?.contains(n) || surfaceRef.current?.contains(n)); const onDown = (e: PointerEvent) => { if (!inside(e.target as Node)) request(false, false); }; let frame = 0; const follow = (e: Event) => { if (e.target instanceof Node && surfaceRef.current?.contains(e.target)) return; cancelAnimationFrame(frame); frame = requestAnimationFrame(place); }; document.addEventListener("pointerdown", onDown); window.addEventListener("scroll", follow, true); window.addEventListener("resize", follow); return () => { cancelAnimationFrame(frame); document.removeEventListener("pointerdown", onDown); window.removeEventListener("scroll", follow, true); window.removeEventListener("resize", follow); }; }); // The highlight glides to the quick range under the pointer or the arrows. React.useLayoutEffect(() => { if (!mounted) return; const el = presetRefs.current[active]; setMark((m) => (el ? (m.x === el.offsetLeft && m.y === el.offsetTop && m.on ? m : { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, on: true, slide: m.on }) : m.on ? { ...m, on: false, slide: false } : m)); }, [active, mounted]); const choose = (i: number) => { commit(presetRanges[i]); request(false); }; const applyTyped = () => { if (!parsed) { setRefused(true); if (!reduced) fieldRef.current?.animate([{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }); return; } commit(parsed, "typed"); request(false); }; const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => { const n = presets.length; const go = (i: number) => { e.preventDefault(); setActive(i); }; if (e.key === "ArrowDown") go(active < 0 ? 0 : Math.min(n - 1, active + 2)); else if (e.key === "ArrowUp") go(active - 2 < 0 ? -1 : active - 2); else if (e.key === "ArrowRight" && active >= 0) go(Math.min(n - 1, active + 1)); else if (e.key === "ArrowLeft" && active >= 0) go(Math.max(0, active - 1)); else if (e.key === "Enter") { e.preventDefault(); if (active >= 0 && !typed.trim()) choose(active); else applyTyped(); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); request(false); } else if (e.key === "Tab") { e.preventDefault(); request(false); } }; const preview = !typed.trim() ? HINT : parsed ? (() => { const r = resolveTimeRange(parsed, now); return `${formatTimeRange(parsed, { now, timeZone, hour12 })} · ${durationShort(r.end.getTime() - r.start.getTime())}${isLive(parsed) ? " · live" : ""}`; })() : refused ? `Couldn’t read that. ${HINT}.` : HINT; const segment = `flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent ${FOCUS}`; return ( <div ref={anchorRef} className={`relative inline-flex max-w-full ${className}`} {...props}> <div role="group" aria-label={label} className={`inline-flex h-8 max-w-full items-stretch overflow-hidden rounded-full border border-border bg-background text-[12.5px] ${disabled ? "opacity-60" : ""}`}> <button type="button" aria-label={`Earlier: the ${durationShort(span)} before`} title="Earlier" disabled={disabled} onClick={() => commit(stepTimeRange(range, -1, new Date()))} className={`w-8 cursor-pointer max-[480px]:w-7 ${segment}`} > <Arrow dir={-1} /> </button> <span aria-hidden="true" className="my-2 w-px shrink-0 bg-border" /> <button ref={triggerRef} type="button" disabled={disabled} aria-haspopup="dialog" aria-expanded={expanded} aria-controls={mounted ? `${uid}-panel` : undefined} aria-label={`${label}: ${words}`} onClick={() => request(!(phase === "open" || phase === "opening"))} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); request(true); } }} className={`flex min-w-0 cursor-pointer items-center gap-1.5 px-2.5 font-medium transition-colors hover:bg-accent disabled:cursor-default ${expanded ? "bg-accent" : ""} ${FOCUS}`} > <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground max-[480px]:hidden"> <circle cx="8" cy="8" r="5.75" stroke="currentColor" strokeWidth="1.4" /> <path d="M8 5.25V8l1.75 1.25" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> </svg> <span className="min-w-0 overflow-hidden whitespace-nowrap"> <TextMorph>{words}</TextMorph> </span> <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 shrink-0 text-muted-foreground" style={{ transform: expanded ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 320ms ${EASE}` }}> <path d="M4.5 6.25 8 9.75l3.5-3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button> <span aria-hidden="true" className="my-2 w-px shrink-0 bg-border" /> <button type="button" aria-label={live ? "Later: already showing the latest" : `Later: the ${durationShort(span)} after`} title={live ? "Already showing the latest" : "Later"} disabled={disabled || live} onClick={() => commit(stepTimeRange(range, 1, new Date()))} className={`w-8 cursor-pointer max-[480px]:w-7 ${segment}`} > <Arrow dir={1} /> </button> <span aria-hidden="true" className="my-2 w-px shrink-0 bg-border" /> <button type="button" aria-pressed={live} aria-label={live ? "Live: pause" : "Paused: go live"} disabled={disabled} onClick={() => { const at = new Date(); const r = resolveTimeRange(range, at); commit(live ? { type: "absolute", start: r.start, end: r.end } : rel(r.end.getTime() - r.start.getTime())); }} className={`cursor-pointer gap-1.5 pl-2.5 pr-3 font-medium max-[480px]:px-2.5 ${segment} ${live ? "text-foreground" : ""}`} > <span aria-hidden="true" className="relative flex size-2 items-center justify-center"> <span className={`absolute inset-0 rounded-full bg-emerald-500 ${live && !reduced ? "animate-[ui-breathe_2.4s_ease-in-out_infinite]" : ""}`} style={{ opacity: live ? 1 : 0, transform: live ? undefined : "scale(0.4)", transition: reduced ? "none" : `opacity 260ms ${EASE}, transform 380ms ${EASE}` }} /> <span className="absolute inset-0 rounded-full shadow-[inset_0_0_0_1.5px_currentColor]" style={{ opacity: live ? 0 : 0.8, transition: reduced ? "none" : `opacity 260ms ${EASE}` }} /> </span> <span className="max-[480px]:hidden"> <TextMorph>{live ? "Live" : "Paused"}</TextMorph> </span> </button> </div> {mounted && createPortal( <div ref={surfaceRef} id={`${uid}-panel`} role="dialog" aria-label={label} onMouseDown={(e) => { if (!(e.target as HTMLElement).matches("input")) e.preventDefault(); }} className="fixed z-[60] overflow-hidden rounded-[16px] border border-border bg-popover p-1.5 text-popover-foreground" style={{ ...fit.theme, left: fit.x, [fit.up ? "bottom" : "top"]: fit.y, width: fit.w, clipPath: expanded ? "inset(0px 0px 0px 0px round 16px)" : fit.up ? "inset(100% 0px 0px 0px round 16px)" : "inset(0px 0px 100% 0px round 16px)", opacity: expanded ? 1 : 0, transition: reduced ? "none" : phase === "closing" ? `clip-path 220ms ${EASE}, opacity 180ms ${EASE} 40ms` : `clip-path 380ms ${EASE}, opacity 200ms ${EASE}`, }} > <div ref={fieldRef} className="flex h-9 items-center gap-2 rounded-[11px] px-2.5 transition-shadow duration-200" style={{ boxShadow: `inset 0 0 0 1px ${refused && !parsed ? "color-mix(in oklab, #ef4444 45%, transparent)" : "color-mix(in oklab, var(--ring) 45%, var(--border))"}` }} > <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground"> <path d="M3 4.5h10M3 8h6M3 11.5h8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> </svg> <input ref={inputRef} value={typed} onChange={(e) => { setTyped(e.target.value); setRefused(false); if (e.target.value) setActive(-1); }} onKeyDown={onInputKey} placeholder="Type a range" aria-label="Type a range" aria-describedby={`${uid}-preview`} aria-activedescendant={active >= 0 ? `${uid}-p${active}` : undefined} autoComplete="off" spellCheck={false} className="h-full min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]" /> {parsed && <span className="shrink-0 rounded-full px-1.5 font-mono text-[10.5px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]">↵</span>} </div> <p id={`${uid}-preview`} className={`px-1 pb-1 pt-1.5 text-[11.5px] transition-colors duration-200 ${refused && !parsed ? "text-red-600 dark:text-red-400" : parsed ? "text-foreground" : "text-muted-foreground"}`}> <Line text={preview} reduced={reduced} /> </p> <div role="listbox" aria-label="Quick ranges" className="relative mt-1 grid grid-cols-2 gap-0.5 border-t border-border pt-1.5" onMouseLeave={() => setActive(-1)}> <span aria-hidden="true" className="pointer-events-none absolute left-0 top-0 rounded-[9px] bg-accent" style={{ width: mark.w, height: mark.h, transform: `translate(${mark.x}px, ${mark.y}px)`, opacity: mark.on ? 1 : 0, transition: reduced ? "none" : `${mark.slide ? `transform 220ms ${EASE}, width 220ms ${EASE}, ` : ""}opacity 160ms ${EASE}`, }} /> {presets.map((p, i) => { const on = i === currentPreset; return ( <button key={p.label} ref={(el) => { presetRefs.current[i] = el; }} id={`${uid}-p${i}`} type="button" role="option" aria-selected={on} tabIndex={-1} onPointerMove={() => active !== i && setActive(i)} onClick={() => choose(i)} className={`relative flex h-8 cursor-pointer items-center justify-between gap-2 rounded-[9px] px-2 text-left text-[12.5px] ${on ? "font-medium text-foreground" : "text-muted-foreground"}`} > <span className="truncate">{p.label}</span> <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 shrink-0 text-foreground"> <path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset 380ms ${EASE} 160ms` }} /> </svg> </button> ); })} </div> {recent.length > 0 && ( <div className="mt-1.5 border-t border-border px-1 pt-2"> <p className="text-[11px] text-muted-foreground">Recent</p> <div className="mt-1.5 flex flex-wrap gap-1 pb-0.5"> {recent.map((r) => ( <button key={formatTimeRange(r, { now, timeZone, hour12 })} type="button" tabIndex={-1} onClick={() => { commit(r, "typed"); request(false); }} className="h-6 max-w-full cursor-pointer truncate rounded-full px-2 text-[11.5px] text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent" > {formatTimeRange(r, { now, timeZone, hour12 })} </button> ))} </div> </div> )} <p className="mt-1.5 border-t border-border px-1 pb-0.5 pt-2 text-[11px] text-muted-foreground">Times in {zoneLabel(timeZone)}</p> </div>, document.body, )} <span className="sr-only" role="status" aria-live="polite"> {announce} </span> </div> );}/* ───────────────────────────────────────────────────────── * TIME RANGE PARSE: what "2h" or "yesterday 14:00–16:00" means * * ranges last N (live), since a moment (live), or a fixed * start and end * typed 15m · 2 hours · last 7 days · today · yesterday · * this week · last month · since 9 · since monday · * 14:00–16:00 · 9am to 5pm · yesterday 14:00-16:00 · * 16 oct 14:00 – 17 oct 09:00 · ISO dates * zones wall-clock times are read and written in the zone * you pass (the viewer's own by default) * ───────────────────────────────────────────────────────── */export type TimeRange = /** The last `duration` ms, ending now. */ | { type: "relative"; duration: number } /** From `start` until now. */ | { type: "since"; start: Date } | { type: "absolute"; start: Date; end: Date };export const MINUTE = 60_000;export const HOUR = 60 * MINUTE;export const DAY = 24 * HOUR;type Wall = { year: number; month: number; day: number; hour: number; minute: number };/* ── Zones ─────────────────────────────────────────────── */const formatters = new Map<string, Intl.DateTimeFormat>();function fmt(timeZone?: string) { const key = timeZone ?? ""; let f = formatters.get(key); if (!f) { f = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", weekday: "short" }); formatters.set(key, f); } return f;}const WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record<string, number>;/** The wall clock at an instant in a zone. */export function wallAt(epoch: number, timeZone?: string) { const p: Record<string, string> = {}; for (const part of fmt(timeZone).formatToParts(new Date(epoch))) p[part.type] = part.value; return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour % 24, minute: +p.minute, second: +p.second, weekday: WEEKDAY[p.weekday] ?? 0 };}/** The instant a wall-clock time happens in a zone (the earlier one when clocks go back). */export function fromWall(w: Wall, timeZone?: string) { const guess = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute); const off = (t: number) => { const a = wallAt(t, timeZone); return Date.UTC(a.year, a.month - 1, a.day, a.hour, a.minute, a.second) - Math.floor(t / 1000) * 1000; }; const a = guess - off(guess - 6 * HOUR); const b = guess - off(guess + 6 * HOUR); const fits = (t: number) => { const x = wallAt(t, timeZone); return x.day === w.day && x.hour === w.hour && x.minute === w.minute; }; if (fits(a) && fits(b)) return Math.min(a, b); if (fits(b)) return b; // a fits, or the time falls in a gap where clocks jump: either way, the moment the clock passes it. return a;}const addDays = (w: Wall, n: number): Wall => { const d = new Date(Date.UTC(w.year, w.month - 1, w.day + n)); return { ...w, year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };};const midnight = (w: Wall): Wall => ({ ...w, hour: 0, minute: 0 });/* ── Resolving and stepping ────────────────────────────── */export function resolveTimeRange(range: TimeRange, now = new Date()): { start: Date; end: Date } { if (range.type === "relative") return { start: new Date(now.getTime() - range.duration), end: now }; if (range.type === "since") return { start: range.start, end: now }; return { start: range.start, end: range.end };}export const isLive = (range: TimeRange) => range.type !== "absolute";/** The same length of time, one window earlier (-1) or later (1). A window that would reach now becomes live again. */export function stepTimeRange(range: TimeRange, dir: -1 | 1, now = new Date()): TimeRange { const { start, end } = resolveTimeRange(range, now); const span = end.getTime() - start.getTime(); const nextEnd = end.getTime() + dir * span; if (dir === 1 && nextEnd >= now.getTime() - 30_000) return { type: "relative", duration: span }; return { type: "absolute", start: new Date(start.getTime() + dir * span), end: new Date(nextEnd) };}export const sameRange = (a: TimeRange, b: TimeRange) => a.type === b.type && (a.type === "relative" ? a.duration === (b as typeof a).duration : a.type === "since" ? a.start.getTime() === (b as typeof a).start.getTime() : a.start.getTime() === (b as typeof a).start.getTime() && a.end.getTime() === (b as typeof a).end.getTime());/* ── Words ─────────────────────────────────────────────── */const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;/** "15 minutes", "4 hours", "7 days". */export function durationWords(ms: number) { if (ms < HOUR || ms % HOUR) return plural(Math.round(ms / MINUTE), "minute"); if (ms < 2 * DAY || ms % DAY) return plural(Math.round(ms / HOUR), "hour"); if (ms % (7 * DAY) === 0 && ms / DAY >= 14) return plural(ms / (7 * DAY), "week"); return plural(Math.round(ms / DAY), "day");}/** "15m", "2h", "3d", "1h 30m". */export function durationShort(ms: number) { const m = Math.round(ms / MINUTE); if (m < 60) return `${m}m`; if (m < 48 * 60) return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`; const d = Math.floor(m / 1440); const h = Math.round((m % 1440) / 60); return h ? `${d}d ${h}h` : `${d}d`;}const pad = (n: number) => String(n).padStart(2, "0");const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];/** What the picker's pill says. */export function formatTimeRange(range: TimeRange, { now = new Date(), timeZone, hour12 = false, compact = false }: { now?: Date; timeZone?: string; hour12?: boolean; compact?: boolean } = {}) { const time = (w: { hour: number; minute: number }) => (hour12 ? `${w.hour % 12 || 12}${w.minute ? `:${pad(w.minute)}` : ""}${w.hour < 12 ? "am" : "pm"}` : `${pad(w.hour)}:${pad(w.minute)}`); const today = wallAt(now.getTime(), timeZone); const yesterday = addDays(today, -1); const dayName = (w: Wall & { weekday?: number }) => { if (w.year === today.year && w.month === today.month && w.day === today.day) return "Today"; if (w.year === yesterday.year && w.month === yesterday.month && w.day === yesterday.day) return "Yesterday"; const date = `${w.day} ${MONTHS[w.month - 1]}${w.year !== today.year ? ` ${w.year}` : ""}`; return compact ? date : `${DAYS[new Date(Date.UTC(w.year, w.month - 1, w.day)).getUTCDay()]} ${date}`; }; // Narrow places drop the spaces round the dash and the comma after the day. const dash = compact ? "–" : " – "; const comma = compact ? " " : ", "; if (range.type === "relative") return `Last ${durationWords(range.duration)}`; const s = wallAt(range.start.getTime(), timeZone); if (range.type === "since") { if (!s.hour && !s.minute) return dayName(s) === "Today" ? "Today so far" : `Since ${dayName(s)}`; return dayName(s) === "Today" ? `Since ${time(s)}` : `Since ${dayName(s)}${comma}${time(s)}`; } const e = wallAt(range.end.getTime(), timeZone); const sameDay = s.year === e.year && s.month === e.month && s.day === e.day; const nextDay = addDays(s, 1); // Whole days read as days: "Yesterday", "Mon 12 Oct – Wed 14 Oct". if (!s.hour && !s.minute && !e.hour && !e.minute) { if (nextDay.year === e.year && nextDay.month === e.month && nextDay.day === e.day) return dayName(s); return `${dayName(s)}${dash}${dayName(addDays(e, -1))}`; } if (sameDay) return `${dayName(s)}${comma}${time(s)}${dash}${time(e)}`; return `${dayName(s)} ${time(s)}${dash}${dayName(e)} ${time(e)}`;}/* ── Typed ranges ──────────────────────────────────────── */const UNIT: Record<string, number> = { s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000, m: MINUTE, min: MINUTE, mins: MINUTE, minute: MINUTE, minutes: MINUTE, h: HOUR, hr: HOUR, hrs: HOUR, hour: HOUR, hours: HOUR, d: DAY, day: DAY, days: DAY, w: 7 * DAY, wk: 7 * DAY, week: 7 * DAY, weeks: 7 * DAY,};const DAY_WORDS: Record<string, number> = { sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4, fri: 5, friday: 5, sat: 6, saturday: 6 };const MONTH_WORDS: Record<string, number> = Object.fromEntries( ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"].flatMap((m, i) => [ [m, i + 1], [m.slice(0, 3), i + 1], ]).concat([["sept", 9]]),);function readTime(t: string): { hour: number; minute: number } | null { if (t === "noon" || t === "midday") return { hour: 12, minute: 0 }; if (t === "midnight") return { hour: 0, minute: 0 }; const m = t.match(/^(\d{1,2})(?:[:.h](\d{2}))?\s?(am|pm)?$/); if (!m) return null; let hour = +m[1]; const minute = m[2] ? +m[2] : 0; if (m[3]) { if (hour < 1 || hour > 12) return null; hour = (hour % 12) + (m[3] === "pm" ? 12 : 0); } else if (!m[2] && hour > 24) return null; if (hour > 23 || minute > 59) return null; return { hour, minute };}/** A date word or numbers, relative to today. */function readDate(t: string, today: Wall & { weekday: number }): Wall | null { if (!t || t === "today") return midnight(today); if (t === "yesterday") return midnight(addDays(today, -1)); if (t in DAY_WORDS) { const back = (today.weekday - DAY_WORDS[t] + 7) % 7; return midnight(addDays(today, -back)); } let m = t.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (m) return { year: +m[1], month: +m[2], day: +m[3], hour: 0, minute: 0 }; m = t.match(/^(\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+)(?: (\d{4}))?$/) ?? null; if (m && MONTH_WORDS[m[2]]) return { year: m[3] ? +m[3] : today.year, month: MONTH_WORDS[m[2]], day: +m[1], hour: 0, minute: 0 }; m = t.match(/^([a-z]+) (\d{1,2})(?:st|nd|rd|th)?(?:,? (\d{4}))?$/); if (m && MONTH_WORDS[m[1]]) return { year: m[3] ? +m[3] : today.year, month: MONTH_WORDS[m[1]], day: +m[2], hour: 0, minute: 0 }; return null;}const TIME_TAIL = /\s*(noon|midday|midnight|\d{1,2}(?:[:.h]\d{2})?\s?(?:am|pm)?)$/;const ISO_MOMENT = /^\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}/;/** "yesterday 14:00", "16 oct", "9am", "2026-10-16 14:00" as a wall time. */function readMoment(text: string, today: Wall & { weekday: number }, fallbackDate?: Wall): { wall: Wall; dated: boolean; timed: boolean } | null { const t = text.trim(); if (ISO_MOMENT.test(t)) { const [d, tm] = t.split(/[t ]/); const date = readDate(d, today); const time = readTime(tm.slice(0, 5)); return date && time ? { wall: { ...date, ...time }, dated: true, timed: true } : null; } const tail = t.match(TIME_TAIL); let datePart = t; let time: { hour: number; minute: number } | null = null; if (tail) { const candidate = readTime(tail[1].replace(/\s/g, "")); // "16 oct" ends in digits that aren't a time; only take the tail when what's left is a date (or nothing). const rest = t.slice(0, tail.index).trim().replace(/,$/, ""); if (candidate && (rest === "" || readDate(rest, today))) { time = candidate; datePart = rest; } } const date = datePart ? readDate(datePart, today) : (fallbackDate ?? midnight(today)); if (!date) return null; return { wall: { ...date, ...(time ?? { hour: 0, minute: 0 }) }, dated: Boolean(datePart), timed: Boolean(time) };}/** Reads a typed range, or null. */export function parseTimeRange(input: string, { now = new Date(), timeZone }: { now?: Date; timeZone?: string } = {}): TimeRange | null { const t = input.toLowerCase().trim().replace(/\s+/g, " ").replace(/[–—]/g, "-"); if (!t) return null; const today = wallAt(now.getTime(), timeZone); const at = (w: Wall) => new Date(fromWall(w, timeZone)); // Durations: 15m, 2 hours, last 7 days. const dur = t.match(/^(?:last |past )?(\d+(?:\.\d+)?) ?([a-z]+)$/); if (dur && UNIT[dur[2]]) return { type: "relative", duration: Math.round(+dur[1] * UNIT[dur[2]]) }; const bare = t.match(/^(?:last (minute|hour|day)|past (minute|hour|day|week))$/); if (bare) return { type: "relative", duration: UNIT[bare[1] ?? bare[2]] }; if (t === "today") return { type: "since", start: at(midnight(today)) }; if (t === "yesterday") return { type: "absolute", start: at(midnight(addDays(today, -1))), end: at(midnight(today)) }; if (t === "this week") return { type: "since", start: at(midnight(addDays(today, -((today.weekday + 6) % 7)))) }; if (t === "last week") { const monday = midnight(addDays(today, -((today.weekday + 6) % 7))); return { type: "absolute", start: at(addDays(monday, -7)), end: at(monday) }; } if (t === "this month") return { type: "since", start: at({ ...midnight(today), day: 1 }) }; if (t === "last month") { const first = { ...midnight(today), day: 1 }; const prev = new Date(Date.UTC(first.year, first.month - 2, 1)); return { type: "absolute", start: at({ ...first, year: prev.getUTCFullYear(), month: prev.getUTCMonth() + 1 }), end: at(first) }; } // Since a moment: since 9, since monday, since 16 oct 14:00. const since = t.match(/^(?:since|from) (.+)$/); if (since && !/ (?:to|until|-) /.test(since[1]) && !/\d-\d/.test(since[1])) { const m = readMoment(since[1], today); if (!m) return null; const start = at(m.wall); return start.getTime() < now.getTime() ? { type: "since", start } : null; } // A span: "a - b", "a to b", or two times joined by a bare hyphen ("yesterday 14:00-16:00"). const body = t.replace(/^(?:from|between) /, ""); let span = body.split(/ (?:-|to|until|and) /); if (span.length !== 2) { const tight = body.match(/^(.*?\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)?)-(\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)?)$/); span = tight ? [tight[1], tight[2]] : []; } if (span.length === 2) { // "yesterday 14:00-16:00": the date on the left carries over to a time-only right side. const a = readMoment(span[0], today); if (!a) return null; const b = readMoment(span[1], today, midnight(a.wall)); if (!b) return null; let start = a.wall; let end = b.wall; if (!b.timed && b.dated) end = addDays(end, 1); // "16 oct - 17 oct" includes the 17th if (fromWall(end, timeZone) <= fromWall(start, timeZone)) end = addDays(end, 1); // 22:00-02:00 runs past midnight // Times without a date that haven't happened yet today mean yesterday's. if (!a.dated && fromWall(start, timeZone) > now.getTime()) { start = addDays(start, -1); end = addDays(end, -1); } return { type: "absolute", start: at(start), end: at(end) }; } // A single day: monday, 16 oct. const day = readMoment(t, today); if (day && day.dated && !day.timed) return { type: "absolute", start: at(day.wall), end: at(addDays(day.wall, 1)) }; if (day && day.timed && !day.dated) return { type: "since", start: at(day.wall) }; return null;}"use client";import * as React from "react";import { durationShort, wallAt } from "./time-range-parse";/* ───────────────────────────────────────────────────────── * TIME BRUSH: drag across any chart to pick the time under it * * drag a band follows the pointer from where you pressed, * with its start, end and length written on it * release the band's span is passed to onSelect (zoom in), * and the band fades as the chart redraws * cancel Escape, or a drag too short to mean anything * * Wrap a chart whose x axis runs from `start` to `end`. Nothing * re-renders while you drag: the band is moved directly. * ───────────────────────────────────────────────────────── */export interface TimeBrushProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> { /** The time at the chart's left edge. */ start: Date; /** The time at its right edge. */ end: Date; onSelect: (range: { start: Date; end: Date }) => void; /** The shortest span worth selecting, in ms. */ minSpan?: number; timeZone?: string; disabled?: boolean;}const pad = (n: number) => String(n).padStart(2, "0");export function TimeBrush({ start, end, onSelect, minSpan = 60_000, timeZone, disabled = false, className = "", children, ...props }: TimeBrushProps) { const rootRef = React.useRef<HTMLDivElement>(null); const bandRef = React.useRef<HTMLDivElement>(null); const fillRef = React.useRef<HTMLSpanElement>(null); const leftRef = React.useRef<HTMLSpanElement>(null); const rightRef = React.useRef<HTMLSpanElement>(null); const tagRef = React.useRef<HTMLSpanElement>(null); const drag = React.useRef<{ x0: number; id: number; left: number; width: number; from: number; to: number } | null>(null); const timeAt = (x: number, width: number) => start.getTime() + (Math.min(Math.max(x, 0), width) / width) * (end.getTime() - start.getTime()); const label = (t: number) => { const w = wallAt(t, timeZone); return `${pad(w.hour)}:${pad(w.minute)}`; }; const paint = (a: number, b: number, width: number) => { const band = bandRef.current; const tag = tagRef.current; const d = drag.current; if (!band || !tag || !d) return; const left = Math.max(0, Math.min(a, b)); const right = Math.min(width, Math.max(a, b)); d.from = timeAt(left, width); d.to = timeAt(right, width); // Transforms only: a 1px fill scaled to the span, and two hairline edges moved into place. fillRef.current!.style.transform = `translateX(${left}px) scaleX(${right - left})`; leftRef.current!.style.transform = `translateX(${left}px)`; rightRef.current!.style.transform = `translateX(${right - 1}px)`; band.style.opacity = right - left > 2 ? "1" : "0"; band.style.transition = "none"; tag.textContent = `${label(d.from)} – ${label(d.to)} · ${durationShort(Math.max(60_000, d.to - d.from))}`; // The tag stays on the chart even when the band is near an edge. const tw = tag.offsetWidth; tag.style.transform = `translateX(${Math.min(Math.max(left + (right - left) / 2 - tw / 2, 0), width - tw)}px)`; }; const finish = (commit: boolean) => { const d = drag.current; const band = bandRef.current; drag.current = null; if (band) { band.style.transition = "opacity 360ms cubic-bezier(0.16,1,0.3,1)"; band.style.opacity = "0"; } if (d && commit && d.to - d.from >= minSpan) onSelect({ start: new Date(d.from), end: new Date(d.to) }); }; React.useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && drag.current) finish(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }); return ( <div ref={rootRef} className={`relative select-none ${disabled ? "" : "cursor-crosshair"} ${className}`} style={{ touchAction: "pan-y" }} onPointerDown={(e) => { if (disabled || e.button !== 0) return; const r = e.currentTarget.getBoundingClientRect(); drag.current = { x0: e.clientX - r.left, id: e.pointerId, left: r.left, width: r.width, from: 0, to: 0 }; }} onPointerMove={(e) => { const d = drag.current; if (!d || e.pointerId !== d.id) return; const x = e.clientX - d.left; if (Math.abs(x - d.x0) > 3 && !e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId); paint(d.x0, x, d.width); }} onPointerUp={(e) => { const d = drag.current; if (!d || e.pointerId !== d.id) return; finish(Math.abs(e.clientX - d.left - d.x0) > 6); }} onPointerCancel={() => finish(false)} {...props} > {children} <div ref={bandRef} aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-0"> <span ref={fillRef} className="absolute inset-y-0 left-0 w-px origin-left bg-primary/[0.09] will-change-transform" /> <span ref={leftRef} className="absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform" /> <span ref={rightRef} className="absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform" /> <span ref={tagRef} className="absolute -top-6 left-0 whitespace-nowrap rounded-full bg-background px-2 py-0.5 font-mono text-[10.5px] tabular-nums text-foreground shadow-[inset_0_0_0_1px_var(--border)] will-change-transform" /> </div> </div> );}Registry item: /r/time-range-picker.json