PerfMonitor
Composable frame statistics, histograms, and store lane readings.
import { type ReactNode } from "react"
import { PerfMonitor, PerfMonitorHistogram, PerfMonitorValue } from "@/components/ui/perf-monitor"
export function FrameReadings({ children }: { children?: ReactNode }) {
return (
<div className="flex flex-wrap items-baseline gap-x-2 lining-nums tabular-nums" data-numeric="">
<span data-perf="frames"><span className="text-muted-foreground">frames </span><PerfMonitorValue metric="frames" /></span>
<span data-perf="p50"><span className="text-muted-foreground">p50 </span><PerfMonitorValue metric="p50" /></span>
<span data-perf="p99"><span className="text-muted-foreground">p99 </span><PerfMonitorValue metric="p99" /></span>
<span data-perf="max"><span className="text-muted-foreground">max </span><PerfMonitorValue metric="max" /></span>
<span data-perf="dropped"><span className="text-muted-foreground">dropped </span><PerfMonitorValue metric="dropped" /></span>
<span data-perf="long"><span className="text-muted-foreground">long </span><PerfMonitorValue metric="long" /></span>
{children}
</div>
)
}
export default function PerfMonitorDemo() {
return (
<PerfMonitor className="w-80 max-w-full">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<PerfMonitorHistogram />
<FrameReadings />
</div>
</PerfMonitor>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/perf-monitor#v2.0.0
npx shadcn@latest add tradecn/ui/perf-monitor#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/perf-monitor#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/perf-monitor#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Copy the files into your project:
components/ui/ perf-monitor.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createFrameSampler, formatMs, type FrameReport, type FrameSampler } from "@/lib/frame-stats" // One sampler and report subscription per monitor; one metadata subscription per lane. Readings // share those snapshots while callers own the labels, collections, controls and surrounding markup. /** What a lane shows, the shape a row store's meta already has. */ export interface LaneMeta { lane: "coalesced" | "ordered" size: number /** Counts batches applied. */ version: number dropped: number seq: number | null gap: boolean /** Wall clock of the last batch, ms since the epoch. */ lastBatchAt: number | null } /** What a lane reads: a row store, or anything with a meta subscription shaped like one. */ export interface MetaSource { getMeta(): LaneMeta subscribeMeta(cb: () => void): () => void } export interface PerfMonitorProps extends Omit<ComponentProps<"div">, "children" | "role" | "aria-label" | "aria-labelledby"> { children: ReactNode role?: never "aria-label"?: never "aria-labelledby"?: never /** Histogram marker and initial budget for an internally created sampler. Default 1000/60. */ budgetMs?: number /** Frames retained by an internally created sampler. Default 600. */ window?: number /** Report interval for an internally created sampler. Default 250ms. */ refreshMs?: number /** Started while mounted and stopped on replacement or unmount. */ sampler?: FrameSampler /** Current report after mount and each report-object change. */ onReport?: (report: FrameReport) => void label?: string } const noop = () => () => {} const ReportContext = createContext<FrameReport | null>(null) const BudgetContext = createContext(1000 / 60) /** The monitor's shared report; this hook adds no subscription or sampler. */ export function usePerfReport(): FrameReport { const report = useContext(ReportContext) if (!report) throw new Error("PerfMonitor readings must be inside PerfMonitor.") return report } export function PerfMonitor({ budgetMs = 1000 / 60, window: frames = 600, refreshMs = 250, sampler: given, onReport, label = "Frame health", children, className, ...props }: PerfMonitorProps) { const [own] = useState(() => given ?? createFrameSampler({ budgetMs, window: frames, refreshMs })) const sampler = given ?? own useEffect(() => { sampler.start() return () => sampler.stop() }, [sampler]) // Called through the sampler, not detached from it, so a class-based sampler keeps its `this`. const subscribeReport = useCallback((cb: () => void) => (sampler.subscribe ? sampler.subscribe(cb) : noop()), [sampler]) const readReport = useCallback(() => sampler.report(), [sampler]) const report = useSyncExternalStore(subscribeReport, readReport, readReport) useEffect(() => { onReport?.(report) // Callback replacement alone must not publish the current report again. // eslint-disable-next-line react-hooks/exhaustive-deps }, [report]) return ( <BudgetContext.Provider value={budgetMs}> <ReportContext.Provider value={report}> <div {...props} role="group" aria-label={label} aria-labelledby={undefined} data-slot="tradecn-perf-monitor" data-dropped={report.dropped} data-frames={report.frames} className={cn("flex flex-col gap-1 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums", className)}> {children} </div> </ReportContext.Provider> </BudgetContext.Provider> ) } export type PerfMetric = "frames" | "p50" | "p99" | "max" | "mean" | "dropped" | "long" export interface PerfMonitorValueProps extends Omit<ComponentProps<"span">, "children"> { metric: PerfMetric /** Null means long-task observation is unavailable. */ format?: (value: number | null, report: FrameReport) => ReactNode } export function PerfMonitorValue({ metric, format, className, ...props }: PerfMonitorValueProps) { const report = usePerfReport() const value = metric === "long" ? (report.longTasksObserved ? report.longTasks : null) : report[metric] const text = value === null ? "n/a" : metric === "p50" || metric === "p99" || metric === "max" || metric === "mean" ? formatMs(value) : metric === "frames" ? value.toLocaleString() : String(value) return <span data-slot="tradecn-perf-monitor-value" data-numeric="" className={cn("lining-nums tabular-nums", className)} {...props}>{format ? format(value, report) : text}</span> } export interface PerfMonitorLaneProps { store: MetaSource children: ReactNode } export interface PerfLaneState { meta: LaneMeta rate: number age: number | null } const LaneContext = createContext<PerfLaneState | null>(null) /** A lane's shared metadata and report-clock readings, without another subscription. */ export function usePerfLane(): PerfLaneState { const lane = useContext(LaneContext) if (!lane) throw new Error("PerfMonitor lane readings must be inside PerfMonitorLane.") return lane } /** Coordinates lane readings without adding markup, including inside a table or list. */ export function PerfMonitorLane({ store, children }: PerfMonitorLaneProps) { const { until: at } = usePerfReport() // Called through the store, so a class-based MetaSource keeps its `this`. const subscribeMeta = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const readMeta = useCallback(() => store.getMeta(), [store]) const meta = useSyncExternalStore(subscribeMeta, readMeta, readMeta) const [seen, setSeen] = useState({ store, at, version: meta.version, rate: 0 }) if (seen.store !== store) { setSeen({ store, at, version: meta.version, rate: 0 }) } else if (seen.at !== at) { const seconds = (at - seen.at) / 1000 setSeen({ store, at, version: meta.version, rate: seconds > 0 ? Math.max(0, meta.version - seen.version) / seconds : 0 }) } const age = meta.lastBatchAt === null ? null : Math.max(0, at - meta.lastBatchAt) const state = useMemo(() => ({ meta, rate: seen.rate, age }), [meta, seen.rate, age]) return <LaneContext.Provider value={state}>{children}</LaneContext.Provider> } export type PerfLaneMetric = "kind" | "rows" | "rate" | "dropped" | "seq" | "gap" | "age" export interface PerfMonitorLaneValueProps extends Omit<ComponentProps<"span">, "children"> { metric: PerfLaneMetric /** Customize a reading from the shared lane state. */ format?: (state: PerfLaneState) => ReactNode } export function PerfMonitorLaneValue({ metric, format, className, ...props }: PerfMonitorLaneValueProps) { const state = usePerfLane() const { meta, rate, age } = state let text: string switch (metric) { case "kind": text = meta.lane; break case "rows": text = meta.size.toLocaleString(); break case "rate": text = rate.toFixed(0); break case "dropped": text = meta.dropped.toLocaleString(); break case "seq": text = meta.seq === null ? "–" : meta.seq.toLocaleString(); break case "gap": text = meta.gap ? "gap" : ""; break case "age": text = age === null ? "–" : formatMs(age); break } return <span data-slot="tradecn-perf-monitor-lane-value" data-numeric="" className={cn("lining-nums tabular-nums", metric === "gap" && "text-stale", className)} {...props}>{format ? format(state) : text}</span> } export type PerfMonitorHistogramProps = Omit<ComponentProps<"svg">, "children"> /** Frame gaps against the monitor's budget, with an accessible summary and per-bin titles. */ export function PerfMonitorHistogram({ className, ...props }: PerfMonitorHistogramProps) { const report = usePerfReport() const budgetMs = useContext(BudgetContext) const bins = report.histogram const width = 160 const height = 28 const gap = 1 const barWidth = width / bins.length const peak = Math.max(1, ...bins) const budgetX = Math.min(width, (budgetMs / report.binMs) * barWidth) // The label reads right of the marker, or left of it past the middle, so it stays inside the chart at any budget. const flip = budgetX > width / 2 const end = report.binMs * (bins.length - 1) return ( <svg role="img" aria-label={`Frame time histogram, ${report.binMs} ms bins to ${end} ms and over: p50 ${formatMs(report.p50)}, p99 ${formatMs(report.p99)}, ${report.dropped} dropped`} viewBox={`0 0 ${width} ${height + 14}`} width={width} height={height + 14} className={cn("shrink-0 overflow-visible", className)} data-perf-histogram {...props}> {bins.map((count, i) => { const h = count === 0 ? 0 : Math.max(1, Math.round((count / peak) * height)) const from = i * report.binMs const label = i === bins.length - 1 ? `${from} ms and over: ${count} frames` : `${from} to ${from + report.binMs} ms: ${count} frames` return ( <rect key={i} x={i * barWidth + gap / 2} y={height - h} width={Math.max(0.5, barWidth - gap)} height={h} rx={1} className="fill-primary" data-bin={i} data-count={count}> <title>{label}</title> </rect> ) })} <line x1={budgetX} x2={budgetX} y1={0} y2={height} strokeDasharray="2 2" className="stroke-muted-foreground" strokeWidth={1} /> <text x={flip ? budgetX - 2 : budgetX + 2} y={height + 13} textAnchor={flip ? "end" : "start"} className="fill-muted-foreground" fontSize={12} data-perf-budget> {formatMs(budgetMs)} </text> </svg> ) }lib/frame-stats.ts // Frame timing, measured the way a per-frame core is judged: the gap between one animation frame and // the next, how many frames missed their slot, how long the longest tasks ran. Pure functions over // the numbers, and a sampler that collects them from requestAnimationFrame and a PerformanceObserver // into a bounded ring, reporting on a timer rather than on every frame, so watching the frame rate // costs almost none of it. export interface FrameReport { /** Frames in the window. */ frames: number p50: number p99: number max: number mean: number /** Frames longer than `droppedAboveMs`. */ dropped: number droppedAboveMs: number /** Long tasks (over 50 ms) seen while sampling, where the browser reports them. */ longTasks: number /** Whether long tasks could be observed at all. */ longTasksObserved: boolean /** Counts per bin, `binMs` wide from zero; the last bin holds everything past the end. */ histogram: number[] binMs: number /** When the window started and ended, ms since the epoch. */ since: number until: number } export interface FrameSamplerOptions { /** Frames kept. Default 600, ten seconds at 60 Hz. */ window?: number /** The frame budget. Default 1000/60. A frame past 1.5 times it counts as dropped. */ budgetMs?: number /** Histogram bin width. Default 2. */ binMs?: number /** Histogram end; the last bin takes everything past it. Default 40. */ maxMs?: number /** How often subscribers hear a new report. Default 250. */ refreshMs?: number /** Injectable for tests. */ raf?: (cb: (t: number) => void) => number caf?: (id: number) => void now?: () => number setTimer?: (cb: () => void, ms: number) => unknown clearTimer?: (id: unknown) => void observe?: boolean } export interface FrameSampler { start(): void stop(): void /** Forget every frame so far. */ reset(): void /** The latest report; the same object until the next refresh. */ report(): FrameReport /** Wakes on each refresh while running. */ subscribe(cb: () => void): () => void readonly running: boolean } export function percentile(sorted: readonly number[], p: number): number { if (!sorted.length) return 0 const i = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)) return sorted[i]! } /** Counts per `binMs`-wide bin from zero to `maxMs`, the last bin taking everything past the end. */ export function histogram(values: readonly number[], binMs: number, maxMs: number): number[] { const bins = Math.max(1, Math.ceil(maxMs / binMs)) const counts = new Array<number>(bins).fill(0) for (const v of values) { if (!Number.isFinite(v) || v < 0) continue counts[Math.min(bins - 1, Math.floor(v / binMs))]!++ } return counts } export interface SummarizeOptions { budgetMs: number binMs: number maxMs: number longTasks: number longTasksObserved: boolean since: number until: number } /** A report from frame gaps. Dropped is a gap past 1.5 times the budget: the frame missed its slot. */ export function summarize(gaps: readonly number[], o: SummarizeOptions): FrameReport { const sorted = [...gaps].sort((a, b) => a - b) const droppedAboveMs = o.budgetMs * 1.5 return { frames: sorted.length, p50: percentile(sorted, 50), p99: percentile(sorted, 99), max: sorted[sorted.length - 1] ?? 0, mean: sorted.length ? sorted.reduce((a, b) => a + b, 0) / sorted.length : 0, dropped: sorted.filter((d) => d > droppedAboveMs).length, droppedAboveMs, longTasks: o.longTasks, longTasksObserved: o.longTasksObserved, histogram: histogram(sorted, o.binMs, o.maxMs), binMs: o.binMs, since: o.since, until: o.until, } } /** "16.7 ms", "3.2 ms". */ export function formatMs(ms: number): string { return `${ms.toFixed(1)} ms` } export function createFrameSampler(options: FrameSamplerOptions = {}): FrameSampler { const size = options.window ?? 600 const budgetMs = options.budgetMs ?? 1000 / 60 const binMs = options.binMs ?? 2 const maxMs = options.maxMs ?? 40 const refreshMs = options.refreshMs ?? 250 const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((id) => cancelAnimationFrame(id)) const now = options.now ?? (() => Date.now()) const setTimer = options.setTimer ?? ((cb, ms) => setInterval(cb, ms)) const clearTimer = options.clearTimer ?? ((id) => clearInterval(id as ReturnType<typeof setInterval>)) const observe = options.observe ?? true // A ring of the last `size` gaps; `head` is the next slot, `count` how many are filled. const ring = new Float64Array(size) let head = 0 let count = 0 let last = -1 let frame = 0 let timer: unknown = null let observer: PerformanceObserver | null = null let longTasks = 0 let observed = false let since = now() let running = false const listeners = new Set<() => void>() let current: FrameReport = summarize([], { budgetMs, binMs, maxMs, longTasks: 0, longTasksObserved: false, since, until: since }) function gaps(): number[] { const out: number[] = new Array(count) const start = (head - count + size) % size for (let i = 0; i < count; i++) out[i] = ring[(start + i) % size]! return out } function refresh() { current = summarize(gaps(), { budgetMs, binMs, maxMs, longTasks, longTasksObserved: observed, since, until: now() }) for (const cb of listeners) cb() } // Each start owns a generation: when caf cannot reach a frame the raf issued — a // popout's frame cancelled through the opener — the stale callback must die on arrival, // and a stop-start replay (StrictMode's, among others) must not revive it, or every // restart would stack another loop reading half gaps. let generation = 0 const tickFor = (mine: number) => { const tick = (t: number) => { if (mine !== generation || !running) return if (last >= 0) { ring[head] = t - last head = (head + 1) % size if (count < size) count++ } last = t frame = raf(tick) } return tick } return { get running() { return running }, start() { if (running) return running = true last = -1 since = now() frame = raf(tickFor(++generation)) timer = setTimer(refresh, refreshMs) // The spec ignores an unsupported entry type with a console warning, not an exception, // so observe() returning says nothing: ask the support list, or a browser without long // tasks would report a measured zero. if (observe && typeof PerformanceObserver !== "undefined" && PerformanceObserver.supportedEntryTypes?.includes("longtask")) { try { observer = new PerformanceObserver((list) => { longTasks += list.getEntries().length }) observer.observe({ type: "longtask", buffered: false }) observed = true } catch { observer = null observed = false } } }, stop() { if (!running) return running = false generation++ caf(frame) if (timer !== null) clearTimer(timer) timer = null observer?.disconnect() observer = null refresh() }, reset() { head = 0 count = 0 last = -1 longTasks = 0 since = now() refresh() }, report: () => current, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, } } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { type ReactNode } from "react"
import { PerfMonitor, PerfMonitorHistogram, PerfMonitorValue } from "@/components/ui/perf-monitor"
export function FrameReadings({ children }: { children?: ReactNode }) {
return (
<div className="flex flex-wrap items-baseline gap-x-2 lining-nums tabular-nums" data-numeric="">
<span data-perf="frames"><span className="text-muted-foreground">frames </span><PerfMonitorValue metric="frames" /></span>
<span data-perf="p50"><span className="text-muted-foreground">p50 </span><PerfMonitorValue metric="p50" /></span>
<span data-perf="p99"><span className="text-muted-foreground">p99 </span><PerfMonitorValue metric="p99" /></span>
<span data-perf="max"><span className="text-muted-foreground">max </span><PerfMonitorValue metric="max" /></span>
<span data-perf="dropped"><span className="text-muted-foreground">dropped </span><PerfMonitorValue metric="dropped" /></span>
<span data-perf="long"><span className="text-muted-foreground">long </span><PerfMonitorValue metric="long" /></span>
{children}
</div>
)
}
export default function PerfMonitorDemo() {
return (
<PerfMonitor className="w-80 max-w-full">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<PerfMonitorHistogram />
<FrameReadings />
</div>
</PerfMonitor>
)
}
Mount the monitor in the view you want to measure. It starts sampling after mount and reports four times a second, so an initial empty reading fills in as frames arrive. Animation frames pause while the tab is hidden, and the first frame back records the whole pause as one gap: it counts as dropped and skews the percentiles and max until it leaves the window, so stop the sampler around a deliberate hide when that matters. In a popout, the default sampler ticks on the opener's frames; pass a sampler created with the popout window's own requestAnimationFrame and cancelAnimationFrame, its timers through setTimer and clearTimer, and observe: false — the long-task observer is built from the opener's realm and would count the opener's tasks — to measure the popout, or stop cancels through the wrong window and the report throttles while the opener hides.
The histogram and numbers show measurements from your browser. The width here lets the readouts wrap on narrow screens.
The monitor stops its sampler on unmount. It needs no row store, feed or load generator for ordinary frame measurements.
Composition
Use the following composition to build a PerfMonitor:
PerfMonitor
├── PerfMonitorHistogram
├── PerfMonitorValue
└── PerfMonitorLane
└── PerfMonitorLaneValue
Place labels, readings, controls, and application content in your own markup. PerfMonitorLane adds no element, so it can coordinate readings inside a native table row. Omit PerfMonitorHistogram for a compact strip.
Measuring a grid
Save Usage as perf-monitor.tsx beside this example, outside components/ui so it cannot overwrite the installed component, to reuse FrameReadings, and install data-grid separately; it also supplies row-store.
The load starts paused. Patches per frame applies one batch per animation frame to a fixed set of 300 rows. Reset measurements clears retained gaps and long-task counts without changing the load.
import { useEffect, useState } from "react"
import { createFrameSampler } from "@/lib/frame-stats"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
import { PerfMonitor, PerfMonitorHistogram, PerfMonitorLane, PerfMonitorLaneValue, usePerfLane } from "@/components/ui/perf-monitor"
import { FrameReadings } from "./perf-monitor"
type Row = { id: string; bid: number; ask: number; size: number }
const ROWS = 300
const columns: ColumnDef<Row>[] = [
{ key: "id", header: "Id", width: 80, accessor: (row) => row.id },
{ key: "bid", header: "Bid", width: 80, numeric: true, accessor: (row) => row.bid, format: (value) => Number(value).toFixed(3) },
{ key: "ask", header: "Ask", width: 80, numeric: true, accessor: (row) => row.ask, format: (value) => Number(value).toFixed(3) },
{ key: "size", header: "Size", width: 80, numeric: true, accessor: (row) => row.size },
]
export function LaneReadings({ label }: { label: string }) {
const { meta } = usePerfLane()
return (
<div className="flex flex-wrap items-baseline gap-x-2 lining-nums tabular-nums" data-perf-lane={label} data-lane={meta.lane} data-numeric="">
<span className="font-medium">{label}</span>
<PerfMonitorLaneValue metric="kind" className="text-muted-foreground" />
<span><span className="text-muted-foreground">rows </span><PerfMonitorLaneValue metric="rows" /></span>
<span><span className="text-muted-foreground">batches/s </span><PerfMonitorLaneValue metric="rate" /></span>
{meta.lane === "coalesced" && <span data-perf-dropped><span className="text-muted-foreground">drop </span><PerfMonitorLaneValue metric="dropped" /></span>}
{meta.lane === "ordered" && <span data-perf-seq><span className="text-muted-foreground">seq </span><PerfMonitorLaneValue metric="seq" />{meta.gap && <> <PerfMonitorLaneValue metric="gap" /></>}</span>}
<span><span className="text-muted-foreground">age </span><PerfMonitorLaneValue metric="age" /></span>
</div>
)
}
export default function PerfMonitorLoadDemo() {
const [store] = useState(() => {
const rows = createRowStore<Row>({ getRowId: (row) => row.id, lane: "coalesced" })
rows.applyDeltas({ upsert: Array.from({ length: ROWS }, (_, i) => ({ id: `R${i}`, bid: 100, ask: 100.01, size: i + 1 })) })
return rows
})
const [sampler] = useState(() => createFrameSampler())
const [patches, setPatches] = useState(0)
useEffect(() => {
if (patches === 0) return
let frame = 0
let step = 0
const tick = () => {
step++
store.applyDeltas({ patch: Array.from({ length: patches }, (_, i) => {
const bid = 100 + ((step + i) % 1000) / 1000
return { id: `R${i % ROWS}`, fields: { bid, ask: bid + 0.01 } }
}) })
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [store, patches])
return (
<>
<div data-demo-controls className="flex flex-wrap items-center gap-3 text-xs lining-nums tabular-nums">
<label className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
Patches per frame
<input type="range" min={0} max={3000} step={100} value={patches} onChange={(event) => setPatches(Number(event.target.value))} className="w-32 max-w-full" />
<span aria-hidden>{patches}</span>
</label>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => sampler.reset()}>Reset measurements</button>
</div>
<div className="w-fit max-w-full space-y-2">
<PerfMonitor sampler={sampler} className="w-80 max-w-full">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<PerfMonitorHistogram />
<FrameReadings><span data-perf-readout="patches/frame"><span className="text-muted-foreground">patches/frame </span>{patches}</span></FrameReadings>
</div>
<PerfMonitorLane store={store}><LaneReadings label="Quotes" /></PerfMonitorLane>
</PerfMonitor>
<div className="h-48 w-fit max-w-full">
<DataGrid store={store} columns={columns} preset="watchlist" label="Measured quotes" announceRowCount="off" />
</div>
</div>
</>
)
}
Custom layout
Compose native tables, reorder lane readings, and place a reset control beside the heading. Install row-store separately for this example.
import { useState } from "react"
import { createFrameSampler } from "@/lib/frame-stats"
import { createRowStore } from "@/lib/row-store"
import { PerfMonitor, PerfMonitorHistogram, PerfMonitorLane, PerfMonitorLaneValue, PerfMonitorValue } from "@/components/ui/perf-monitor"
export default function PerfMonitorLayoutDemo() {
const [sampler] = useState(() => createFrameSampler({ budgetMs: 20, window: 120, refreshMs: 500 }))
const [feeds] = useState(() => {
const quotes = createRowStore<{ id: string }>({ getRowId: (row) => row.id, lane: "coalesced" })
quotes.applyDeltas({ upsert: [{ id: "ZN" }, { id: "ZF" }] })
const orders = createRowStore<{ id: string }>({ getRowId: (row) => row.id, lane: "ordered" })
return [{ label: "Orders", store: orders }, { label: "Quotes", store: quotes }]
})
return (
<PerfMonitor sampler={sampler} budgetMs={20} label="Desk measurements" className="w-80 max-w-full gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Desk measurements</h2>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => sampler.reset()}>Reset measurements</button>
</div>
<table className="w-full text-left">
<caption className="text-left text-muted-foreground">Frame gaps</caption>
<tbody>
<tr><th scope="row" className="font-normal">p99</th><td className="text-right"><PerfMonitorValue metric="p99" /></td></tr>
<tr><th scope="row" className="font-normal">frames</th><td className="text-right"><PerfMonitorValue metric="frames" /></td></tr>
<tr><th scope="row" className="font-normal">mean</th><td className="text-right"><PerfMonitorValue metric="mean" /></td></tr>
</tbody>
</table>
<table className="w-full text-left">
<caption className="text-left text-muted-foreground">Feeds</caption>
<thead><tr><th scope="col" className="font-normal">Feed</th><th scope="col" className="text-right font-normal">Age</th><th scope="col" className="text-right font-normal">Rows</th></tr></thead>
<tbody>
{feeds.map(({ label, store }) => (
<PerfMonitorLane key={label} store={store}>
<tr><th scope="row" className="font-normal">{label}</th><td className="text-right"><PerfMonitorLaneValue metric="age" /></td><td className="text-right"><PerfMonitorLaneValue metric="rows" /></td></tr>
</PerfMonitorLane>
))}
</tbody>
</table>
<div className="flex flex-wrap items-center justify-between gap-2">
<span>Tail gap <PerfMonitorValue metric="p99" /></span>
<PerfMonitorHistogram />
</div>
</PerfMonitor>
)
}
API Reference
Props
PerfMonitor accepts native div props and a ref, except role, aria-label, and aria-labelledby. The root always uses role="group", with label owning the accessible name.
| Prop | Type | Default | Purpose |
|---|---|---|---|
children |
ReactNode |
Required | Readings and caller-owned markup; conditional content is supported. |
budgetMs |
number |
1000 / 60 |
Histogram marker and initial budget for an internally created sampler. |
window |
number |
600 |
Recent frame gaps retained by an internally created sampler. |
refreshMs |
number |
250 |
Report interval in milliseconds for an internally created sampler. |
sampler |
FrameSampler |
Created internally | Sampler to start, subscribe to, and stop. |
onReport |
(report: FrameReport) => void |
Omitted | Current report after mount and on report-object changes. |
label |
string |
"Frame health" |
Accessible name of the outer group. |
className |
string |
Omitted | Classes on the outer group. |
PerfMonitorValue
A native span showing one frame reading. It accepts native props, a ref, and classes; place its label in your composition.
| Prop | Type | Default | Purpose |
|---|---|---|---|
metric |
PerfMetric |
Required | frames, p50, p99, max, mean, dropped, or long. |
format |
(value: number | null, report: FrameReport) => ReactNode |
Built-in formatting | Replace the value's content. null means long-task observation is unavailable. |
Milliseconds use one decimal place and an ms suffix. Frame counts use locale separators; dropped and long-task counts use plain digits. Unavailable long-task observation displays n/a.
PerfMonitorHistogram
An SVG with native props, a ref, and classes. It reads the same report and budget as the other parts; it creates no sampler.
Its default accessible name describes the bins and summary statistics. Supply an aria-label or aria-labelledby if your composition needs another name.
PerfMonitorLane
A provider with no DOM wrapper. Each instance owns one store metadata subscription and calculates the lane's rate and age from the monitor's report clock.
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
MetaSource |
Required | Store whose metadata the readings share. |
children |
ReactNode |
Required | Caller-owned lane markup and readings. |
PerfMonitorLaneValue
A native span with native props, a ref, and classes. Labels and the choice of readings belong to the composition.
| Prop | Type | Default | Purpose |
|---|---|---|---|
metric |
PerfLaneMetric |
Required | kind, rows, rate, dropped, seq, gap, or age. |
format |
(state: PerfLaneState) => ReactNode |
Built-in formatting | Replace the value's content using the shared metadata, rate, and age. |
gap prints the word only when true. Choose dropped for coalesced lanes or seq and gap for ordered lanes, as LaneReadings does in Measuring a grid. Save that example as perf-monitor-load.tsx to reuse its lane composition.
Hooks
usePerfReport() returns the current FrameReport within PerfMonitor. usePerfLane() returns { meta: LaneMeta, rate: number, age: number | null } within PerfMonitorLane; this shape is exported as PerfLaneState. Both reuse the existing context without adding subscriptions or lifecycle effects, and throw outside their provider.
The group retains data-slot="tradecn-perf-monitor", data-frames, and data-dropped. Value spans carry tradecn-perf-monitor-value or tradecn-perf-monitor-lane-value slots.
The histogram retains data-perf-histogram, data-perf-budget, and its bin/count marks. Application selectors such as data-perf and data-perf-lane belong to the copied compositions.
Readings do not announce each refresh. Keep labels beside values, give custom controls accessible names, and use native row and column headers for tables. Controls retain their native keyboard behavior.
What it measures
A frame gap is the difference between consecutive requestAnimationFrame timestamps. PerfMonitorValue prints any one FrameReport reading: retained gaps, p50, p99, maximum, mean, dropped count, or observed long-task count. The strip in Usage composes six of them. Gaps are measured in milliseconds; the default budget displays as 16.7 ms.
dropped counts gaps strictly greater than 1.5 times the sampler's budget: over 25 ms by default. It counts qualifying gaps, not the number of missed display refreshes. A gap over budget but at or below this threshold does not count as dropped.
The sampler retains at most window gaps, about ten seconds at 60 Hz by default. Its first animation frame establishes a timestamp without adding a gap.
Long tasks accumulate across sampling until reset(); they do not roll out with the frame window. The long-task reading prints n/a when observation is unavailable or disabled.
The histogram
The chart uses one hue and a dashed, labeled budget marker. The default histogram has 20 bins: [0, 2), [2, 4), through [36, 38), then 38 ms and over. maxMs: 40 determines the bin count, so the final bin starts at 38 ms and includes all larger gaps.
Each bar's hover title names its range and count. The chart's accessible name includes the bin width, overflow boundary, p50, p99, and dropped count; the Usage example places the same summary readings beside it.
A supplied sampler determines the bins, while the monitor's budgetMs sets the marker, clamped to the chart's right edge. Keep that prop aligned with the sampler's budget. The marker's label reads to its right, or to its left once the marker passes the middle of the chart, so it stays inside the chart at any budget.
The lanes
Pass a row-store or another MetaSource to each PerfMonitorLane. Choose stable keys when mapping lanes; their labels and order belong to your composition.
MetaSource member |
Type | Contract |
|---|---|---|
getMeta |
() => LaneMeta |
Returns a stable snapshot until metadata changes. |
subscribeMeta |
(cb: () => void) => () => void |
Notifies subscribers when metadata changes; returns an unsubscribe function. |
All LaneMeta fields are required:
| Field | Type | Purpose |
|---|---|---|
lane |
"coalesced" | "ordered" |
Lane kind, reaching your readings through usePerfLane().meta. |
size |
number |
Current row count. |
version |
number |
Counter used to calculate batches per second. |
dropped |
number |
Producer's dropped-message count. The grid example shows it for coalesced lanes. |
seq |
number | null |
Latest sequence; null prints as –. The grid example shows it for ordered lanes. |
gap |
boolean |
Its own reading, printing gap when true; the grid example renders it beside seq. |
lastBatchAt |
number | null |
Last batch's wall-clock timestamp in milliseconds since the epoch; null prints its age as –. |
Lanes subscribe directly to their stores and can redraw between frame reports. Batches per second starts at zero, then uses the nonnegative change in version divided by elapsed report time, rounded to a whole number for display.
It updates when report.until changes; if that timestamp moves backward, the rate resets to zero. Replacing the lane source resets its rate to zero.
A row store increments version on both applyDeltas() and clear(), so a clear also counts toward that rate.
Age is max(0, report.until - lastBatchAt) in milliseconds, using the report's timestamp even when fresh metadata arrives between reports. Lane drops describe producer messages; the frame dropped reading describes frame gaps. Add your own IPC batch size, queue depth, or socket state as children.
What it costs
After the first frame following a start or reset, each animation frame writes one gap to a bounded typed-array ring. The report timer copies and sorts the retained gaps to calculate statistics and notifies subscribers, four times a second by default.
The root owns one report subscription; each lane owns one metadata subscription shared by its readings. Static child content does not consume either context.
Frame ticks do not themselves trigger renders; store notifications and parent updates can still cause renders between reports.
The report
Import createFrameSampler and the FrameReport and FrameSampler types from @/lib/frame-stats. Pass a sampler to the monitor when a test script, replay gate, or screenshot script also needs report().
FrameReport field |
Type | Meaning |
|---|---|---|
frames |
number |
Number of retained gaps. |
p50, p99 |
number |
Nearest-rank percentiles of retained gaps, in milliseconds; zero when empty. |
max, mean |
number |
Maximum and arithmetic mean gap in milliseconds; zero when empty. The Usage strip omits mean. |
dropped |
number |
Retained gaps strictly greater than droppedAboveMs. |
droppedAboveMs |
number |
Sampler budget multiplied by 1.5. |
longTasks |
number |
Number of browser long-task entries observed since construction or reset, retained across stop/start. |
longTasksObserved |
boolean |
Whether long-task observation was established. Initially false. |
histogram |
number[] |
Gap counts in bins from zero; the final bin includes all overflow. |
binMs |
number |
Width of each ordinary bin in milliseconds. |
since |
number |
Sampler's latest start or reset time, initially its construction time, in milliseconds since the epoch. |
until |
number |
Report refresh time in milliseconds since the epoch, initially its construction time. |
since is not the timestamp of the oldest retained gap and does not advance when the ring fills. The report contains frame statistics only; lane metadata and custom readouts are separate.
createFrameSampler(options) accepts window, budgetMs, and refreshMs with the same defaults as the monitor. It also accepts binMs (default 2), maxMs (default 40), and observe (default true; set false to disable long-task observation). Tests can inject raf, caf, now, setTimer, and clearTimer.
FrameSampler member |
Behavior |
|---|---|
start() |
Starts animation frames, the report timer, and optional long-task observation. Does nothing if already running. |
stop() |
Cancels sampling, disconnects observation, and publishes a final report. Does nothing if already stopped. |
reset() |
Clears gaps and long-task count, resets since, and publishes a report without changing whether sampling is running. |
report() |
Returns the cached FrameReport; it does not take a fresh sample. |
subscribe(cb) |
Notifies on reports from the timer, reset(), or an active stop(); returns an unsubscribe function. |
running |
Readonly boolean indicating whether sampling is running. |
Stopping and restarting retains gaps and the long-task count. Restarting resets since and the previous animation-frame timestamp, so the pause does not become a gap.
Call reset() for a fresh measurement. report() returns the same object until the next report is published; start() alone does not publish one.
The monitor starts its selected sampler after mount, stops it on unmount, and stops the old sampler before starting a replacement. This applies to supplied samplers too, so avoid sharing one across monitors with independent lifetimes. The initial sampler is retained as the fallback if sampler is later omitted.
An internally created sampler captures window, budgetMs, and refreshMs on the first render. Changing those props does not reconfigure it; remount or provide a new sampler.
A supplied sampler owns its configuration. In either case, changing the monitor's budgetMs still moves the chart marker.
onReport runs after mount with the current snapshot, which can be the initial empty report, and after report-object changes. Replacing the callback alone does not invoke it. React development effect replay can repeat the initial callback.
percentile(sorted, p), histogram(values, binMs, maxMs), and summarize(gaps, options) expose the arithmetic as pure functions. percentile expects sorted values and returns zero for an empty array; summarize sorts its own copy. formatMs(ms) formats one decimal place with an ms suffix.
What it does not do
The monitor shows measurements without deciding pass or fail. Set thresholds before a run and measure on the target machine.
Frame gaps do not identify their cause or measure script time inside your work; time that work separately and render it in your composition. Long-task counts depend on the browser's reporting support.
Tokens
The install adds the stale token if absent, with the mono and accessible font tokens and the hyperlegible remap its readings use.
This page is docs/perf-monitor.md at v2.0.0.