Skip to main content

ns-ui

Night Store

A cache diagnostics rack where every key-group is a storage-heater brick holding heat: a hit charges it toward full glyph-density stipple, idle time cools it on a decay matched to its own TTL, and a real eviction contracts the rack by exactly one slot. Keys hit over and over flicker cold-hot instead of settling — the visual signature of a thrashing cache.

Use when a live cache/session-store health panel where the question is which key-groups carry the heat, what's about to be evicted, and whether traffic is thrashing a given key — each brick's own hit history drives its own temperature and nothing else, so re-fetch loops show up as a visible cold-hot flicker on one brick over time. Pick heatmap-calendar-tide instead for magnitudes painted onto a fixed calendar grid of time slots, or table-heat-shimmer for a sortable ops table where hot ROWS need to be found by motion — neither tracks a live entry's own charge/decay across real hit and eviction events the way this rack does.

Install

npx shadcn add https://design.helpmarq.com/r/night-store.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/night-store/component.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";

// ---------------------------------------------------------------------------
// HeaterRack — a cache diagnostics panel where every key-group is a
// storage-heater brick holding heat. One rule drives every brick: its
// temperature T is a pure function of elapsed time since its last hit,
// T(dt) = exp(-dt / tau) with tau = ttlMs/3000 seconds — chosen so a
// never-hit entry already reads visibly cold at roughly a third of its
// lifetime, early enough to PREDICT an eviction rather than report one. A
// hit resets a brick's clock (T snaps to 1); the "single fast charge ease"
// the brief asks for is realized as a plain ~220ms CSS opacity transition on
// each stipple dot, not a hand-rolled JS ramp — the same trick this
// registry already uses for its other scalar-driven surfaces (see
// button-cooldown-heat's --heat). Temperature never touches color: it is
// rendered purely as glyph-density STIPPLE (a fixed pool of deterministic,
// per-brick dot positions, radially ranked so a warming brick fills from its
// own center outward) quantized to a 5-level ramp — level = floor(T*5).
//
// Every visual property of a brick derives from ITS OWN T and nothing else.
// There is no shared clock, stagger, or wave across the rack: two bricks
// with identical hit histories always look identical, and a brick being hit
// over and over while its neighbors sit idle visibly flickers cold-hot
// against a static field — that contrast is the component's only
// diagnostic value, so nothing here is allowed to move the whole rack in
// unison.
//
// The rack's order is exactly the order of the `entries` prop and is NEVER
// re-sorted by temperature — re-sorting would make it impossible to track
// one brick's heat over time, which is the entire point of a diagnostics
// view. An id that drops out of `entries` (a real eviction) doesn't vanish;
// it plays a brief "leaving" contraction (max-width/opacity/margin easing
// to zero) and only then leaves the DOM, so the rack visibly contracts by
// exactly one slot instead of jump-cutting.
//
// Each brick is `role="listitem"` carrying the full diagnostic sentence in
// its own aria-label (key-group, hit count, a T/tau-derived eviction
// forecast) so a screen reader can walk the list without touching anything.
// The stipple SVG itself is aria-hidden. Anything beyond that summary — raw
// temperature, TTL, last-hit age — sits behind a real per-brick disclosure
// button (never a hover-only tooltip), which is also what keeps the
// component keyboard-reachable: a diagnostics grid otherwise has no natural
// "control" at all. Header stats (hit ratio, entry count, evictions/min)
// are live numbers; a separate sr-only aria-live=polite region announces
// only when `hitRatio` crosses the caller-supplied
// `hitRatioAlertThreshold`, never on every tick.
//
// DOM + SVG + CSS only, no canvas. Every ink value is --foreground over
// --ns-muted/--border/--background; --ns-accent appears solely on the
// disclosure button's focus ring.
// ---------------------------------------------------------------------------

export interface CacheEntryInput {
  /** stable identity for this cache entry/shard */
  id: string;
  /** the key-group label rendered on the brick, e.g. "user:profile" */
  keyGroup: string;
  /** this entry's TTL in ms — tau (the decay time-constant) is ttlMs / 3 */
  ttlMs: number;
  /** cumulative hit count, owned by the caller */
  hitCount: number;
  /** epoch ms of this entry's most recent hit (or creation, if never hit again) */
  lastHitAt: number;
}

export interface HeaterRackProps {
  /**
   * The tracked key-groups, in the fixed diagnostic order the rack renders
   * them — this order is never touched by the component. An id that drops
   * out of a later `entries` array is treated as a real eviction and
   * animates out; a new id is appended as a new brick.
   */
  entries: CacheEntryInput[];
  /** rolling hit ratio, 0..1, shown in the header. Caller computes this — the rack doesn't infer it from `entries`. */
  hitRatio: number;
  /** evictions observed in the last 60s, shown in the header. */
  evictionsPerMin: number;
  /** if set, the sr-only live region announces only when hitRatio crosses this value (either direction). */
  hitRatioAlertThreshold?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

interface BrickRow {
  id: string;
  entry: CacheEntryInput;
  leaving: boolean;
}

const POOL_SIZE = 16;
const EVICT_TH = 0.15; // T below this reads as "at risk now" in the forecast text
const POLL_MS_NORMAL = 220;
const POLL_MS_REDUCED = 1000;
const LEAVE_MS = 260;

function hashStr(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

function mulberry32(seed: number) {
  let a = seed;
  return function () {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Deterministic, per-id stipple pool in normalized [0,1] space, ranked by
 *  distance from center so the reveal order (see `visibleCount`) fills a
 *  warming brick from its own core outward. Computed once per id and cached
 *  on a ref for the component's lifetime — positions never change. */
function stipplePool(id: string): { x: number; y: number }[] {
  const rng = mulberry32(hashStr(id));
  const pts = Array.from({ length: POOL_SIZE }, () => ({ x: rng(), y: rng() }));
  pts.sort((a, b) => Math.hypot(a.x - 0.5, a.y - 0.5) - Math.hypot(b.x - 0.5, b.y - 0.5));
  return pts;
}

function computeT(nowMs: number, entry: CacheEntryInput): number {
  const dt = Math.max(0, (nowMs - entry.lastHitAt) / 1000);
  if (dt === 0) return 1;
  const tau = Math.max(0.05, entry.ttlMs / 3000);
  return Math.exp(-dt / tau);
}

function levelFromT(t: number): number {
  return Math.min(4, Math.max(0, Math.floor(t * 5)));
}

function visibleCount(level: number): number {
  return Math.round((level / 4) * POOL_SIZE);
}

function fmtDuration(ms: number): string {
  const s = Math.max(0, Math.round(ms / 1000));
  if (s < 60) return `${s}s`;
  return `${Math.floor(s / 60)}m ${s % 60}s`;
}

function forecastText(t: number, entry: CacheEntryInput): string {
  if (t >= 0.94) return "just hit, full heat";
  if (t <= EVICT_TH) return "cold, at risk of eviction now";
  const tau = Math.max(0.05, entry.ttlMs / 3000);
  const secs = Math.max(1, Math.round(tau * Math.log(t / EVICT_TH)));
  return `cooling, likely evicted in ~${secs}s`;
}

function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

export function HeaterRack({
  entries,
  hitRatio,
  evictionsPerMin,
  hitRatioAlertThreshold,
  className = "",
}: HeaterRackProps) {
  const uid = useId();
  const reduced = useReducedMotion();

  // -- ordered rows: mirrors `entries` exactly while an id is present, keeps
  // a departed id around (marked `leaving`) just long enough to animate the
  // rack's contraction, and never reorders anything itself.
  const [rows, setRows] = useState<BrickRow[]>(() =>
    entries.map((entry) => ({ id: entry.id, entry, leaving: false }))
  );
  const leaveTimersRef = useRef<Map<string, number>>(new Map());

  useEffect(() => {
    const byId = new Map(entries.map((e) => [e.id, e]));
    setRows((prev) => {
      const next: BrickRow[] = [];
      const seen = new Set<string>();
      for (const row of prev) {
        seen.add(row.id);
        const live = byId.get(row.id);
        if (live) {
          next.push({ id: row.id, entry: live, leaving: false });
        } else if (row.leaving) {
          next.push(row); // already animating out
        } else {
          next.push({ ...row, leaving: true });
          const timer = window.setTimeout(() => {
            setRows((cur) => cur.filter((r) => r.id !== row.id));
            leaveTimersRef.current.delete(row.id);
          }, LEAVE_MS);
          const prior = leaveTimersRef.current.get(row.id);
          if (prior) window.clearTimeout(prior);
          leaveTimersRef.current.set(row.id, timer);
        }
      }
      for (const entry of entries) {
        if (!seen.has(entry.id)) next.push({ id: entry.id, entry, leaving: false });
      }
      return next;
    });
  }, [entries]);

  useEffect(() => {
    const timers = leaveTimersRef.current;
    return () => {
      timers.forEach((t) => window.clearTimeout(t));
      timers.clear();
    };
  }, []);

  // -- periodic recompute of T for every brick. T is a pure function of
  // elapsed time, so there's nothing to simulate frame-by-frame — this just
  // re-samples it, at 1Hz under reduced motion per the spec, faster
  // otherwise so the fast charge-ease reads smoothly.
  const [tick, setTick] = useState(0);
  const reducedRef = useRef(reduced);
  reducedRef.current = reduced;
  useEffect(() => {
    let id = 0;
    const schedule = () => {
      id = window.setTimeout(() => {
        setTick((t) => t + 1);
        schedule();
      }, reducedRef.current ? POLL_MS_REDUCED : POLL_MS_NORMAL);
    };
    schedule();
    return () => window.clearTimeout(id);
  }, []);

  const now = useMemo(() => Date.now(), [tick]);

  const poolCacheRef = useRef<Map<string, { x: number; y: number }[]>>(new Map());
  const poolFor = (id: string) => {
    let pool = poolCacheRef.current.get(id);
    if (!pool) {
      pool = stipplePool(id);
      poolCacheRef.current.set(id, pool);
    }
    return pool;
  };

  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
  const toggleExpanded = (id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  // -- threshold-crossing announcement only, never a per-tick readout.
  const [announce, setAnnounce] = useState("");
  const prevAboveRef = useRef<boolean | null>(null);
  useEffect(() => {
    if (hitRatioAlertThreshold == null) return;
    const above = hitRatio >= hitRatioAlertThreshold;
    if (prevAboveRef.current !== null && prevAboveRef.current !== above) {
      const pct = Math.round(hitRatioAlertThreshold * 100);
      setAnnounce(
        above
          ? `Hit ratio recovered above ${pct}%.`
          : `Hit ratio dropped below ${pct}% — cache under pressure.`
      );
    }
    prevAboveRef.current = above;
  }, [hitRatio, hitRatioAlertThreshold]);

  return (
    <div className={`ns-hr-root ${className}`}>
      <style>{CSS}</style>

      <dl className="m-0 flex flex-wrap gap-x-6 gap-y-2 p-0" aria-label="Cache health">
        <div className="flex flex-col gap-0.5">
          <dt className="m-0 font-mono text-[10px] uppercase tracking-wider text-ns-muted">Hit ratio</dt>
          <dd className="m-0 font-mono text-sm font-semibold tabular-nums text-foreground">
            {Math.round(hitRatio * 100)}%
          </dd>
        </div>
        <div className="flex flex-col gap-0.5">
          <dt className="m-0 font-mono text-[10px] uppercase tracking-wider text-ns-muted">Entries</dt>
          <dd className="m-0 font-mono text-sm font-semibold tabular-nums text-foreground">{entries.length}</dd>
        </div>
        <div className="flex flex-col gap-0.5">
          <dt className="m-0 font-mono text-[10px] uppercase tracking-wider text-ns-muted">Evictions/min</dt>
          <dd className="m-0 font-mono text-sm font-semibold tabular-nums text-foreground">{evictionsPerMin}</dd>
        </div>
      </dl>

      <ul className="ns-hr-rack m-0 mt-3 flex flex-wrap p-0" style={{ listStyle: "none" }}>
        {rows.map((row, index) => {
          const t = row.leaving ? 0 : computeT(now, row.entry);
          const level = levelFromT(t);
          const shown = visibleCount(level);
          const pool = poolFor(row.id);
          const isExpanded = expanded.has(row.id);
          const detailId = `${uid}-detail-${row.id}`;
          const forecast = forecastText(t, row.entry);
          const hitNoun = row.entry.hitCount === 1 ? "hit" : "hits";
          const ariaLabel = `${row.entry.keyGroup}, ${row.entry.hitCount} ${hitNoun}, ${forecast}`;

          return (
            <li
              key={row.id}
              role="listitem"
              aria-label={ariaLabel}
              data-ns-brick-index={index}
              className={`ns-hr-brick ${row.leaving ? "ns-hr-brick-leaving" : ""}`}
              style={{ "--t": t.toFixed(3) } as CSSProperties}
            >
              <div className="ns-hr-stipple" aria-hidden="true">
                <svg viewBox="0 0 1 1" preserveAspectRatio="none" focusable="false">
                  {pool.map((p, k) => (
                    <circle
                      key={k}
                      cx={p.x}
                      cy={p.y}
                      r={0.055}
                      className="ns-hr-dot"
                      style={{ opacity: k < shown ? 1 : 0 }}
                    />
                  ))}
                </svg>
              </div>

              <div className="mt-1.5 flex items-baseline justify-between gap-1.5">
                <span className="truncate font-mono text-[10px] text-foreground">{row.entry.keyGroup}</span>
                <span className="shrink-0 font-mono text-[10px] tabular-nums text-ns-muted">
                  {row.entry.hitCount}
                </span>
              </div>

              <button
                type="button"
                data-ns-disclosure
                aria-expanded={isExpanded}
                aria-controls={detailId}
                onClick={() => toggleExpanded(row.id)}
                className="ns-hr-disclosure mt-1.5 flex w-full items-center justify-center rounded-[6px] border border-border py-0.5 text-[11px] leading-none text-ns-muted transition-colors duration-150 hover:border-foreground/30 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
              >
                <span className="sr-only">
                  {isExpanded ? "Hide" : "Show"} details for {row.entry.keyGroup}
                </span>
                <span aria-hidden="true">{isExpanded ? "−" : "+"}</span>
              </button>

              {isExpanded ? (
                <dl
                  id={detailId}
                  data-ns-detail
                  className="mt-1.5 flex flex-col gap-0.5 border-t border-border pt-1.5"
                >
                  <div className="flex justify-between gap-2">
                    <dt className="m-0 font-mono text-[9px] text-ns-muted">Temp</dt>
                    <dd className="m-0 font-mono text-[9px] tabular-nums text-foreground">{Math.round(t * 100)}%</dd>
                  </div>
                  <div className="flex justify-between gap-2">
                    <dt className="m-0 font-mono text-[9px] text-ns-muted">TTL</dt>
                    <dd className="m-0 font-mono text-[9px] tabular-nums text-foreground">
                      {fmtDuration(row.entry.ttlMs)}
                    </dd>
                  </div>
                  <div className="flex justify-between gap-2">
                    <dt className="m-0 font-mono text-[9px] text-ns-muted">Last hit</dt>
                    <dd className="m-0 font-mono text-[9px] tabular-nums text-foreground">
                      {fmtDuration(now - row.entry.lastHitAt)} ago
                    </dd>
                  </div>
                  <div className="flex justify-between gap-2">
                    <dt className="m-0 font-mono text-[9px] text-ns-muted">Forecast</dt>
                    <dd className="m-0 text-right font-mono text-[9px] text-foreground">{forecast}</dd>
                  </div>
                </dl>
              ) : null}
            </li>
          );
        })}
      </ul>

      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}

const CSS = `
.ns-hr-brick {
  position: relative;
  box-sizing: border-box;
  width: 104px;
  max-width: 104px;
  margin: 0 8px 8px 0;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background-color: color-mix(in srgb, var(--background), var(--ns-muted) calc(var(--t, 0) * 7%));
  overflow: hidden;
  transition: max-width 260ms cubic-bezier(0.4,0,0.2,1), opacity 260ms ease-in, margin-right 260ms ease-in, padding-left 260ms ease-in, padding-right 260ms ease-in, border-width 260ms ease-in, background-color 220ms ease-out;
}
.ns-hr-brick-leaving {
  max-width: 0;
  width: 0;
  opacity: 0;
  margin-right: 0;
  padding-left: 0;
  padding-right: 0;
  border-left-width: 0;
  border-right-width: 0;
}
.ns-hr-stipple { width: 100%; height: 40px; }
.ns-hr-stipple svg { width: 100%; height: 100%; display: block; }
.ns-hr-dot { fill: var(--foreground); transition: opacity 220ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .ns-hr-brick { transition: none; }
  .ns-hr-dot { transition: none; }
}
`;
Build spec

Build a cache diagnostics panel (`HeaterRack`) where every tracked key-group renders as a storage-heater brick holding heat. Props: `entries: CacheEntryInput[]` (each `{ id, keyGroup, ttlMs, hitCount, lastHitAt }`, owned and updated by the caller from real cache events), `hitRatio: number` (0..1, caller-computed rolling ratio), `evictionsPerMin: number`, an optional `hitRatioAlertThreshold?: number`, and `className`. MECHANISM (the one rule that drives every brick, and the falsifiable constraint of the whole component): a brick's temperature T is a PURE FUNCTION of elapsed time since its own last hit — `T(dt) = exp(-dt / tau)` where `tau = ttlMs / 3000` seconds (chosen so a never-hit entry already reads visibly cold at roughly a third of its lifetime, early enough to PREDICT an eviction rather than report one after the fact) and `dt = (now - lastHitAt) / 1000`; a hit simply resets `lastHitAt` to now, which snaps T back to 1. Every visual property of a brick derives from that brick's OWN T and nothing else — no global shimmer, wave, or stagger is allowed anywhere in the rack, because once bricks move together for ambience the thrash-flicker of a key being hit-then-cooled-then-hit-again over and over becomes invisible, and that flicker is the component's only diagnostic value. Re-sample T periodically (a plain `setTimeout` re-tick loop, not a physics simulation — there is nothing to accumulate since T is a pure function of elapsed time) at roughly 220ms under normal motion and exactly 1000ms (1Hz) under `prefers-reduced-motion: reduce`, per `matchMedia` with a live change listener. RENDERING — glyph-density stipple, never color: temperature is communicated purely by how many of a brick's dots are visible, quantized to a fixed 5-level ramp (`level = floor(T*5)`, clamped 0-4). Each brick has its own small fixed pool of 16 dot positions in a `viewBox="0 0 1 1"` SVG, generated once by a deterministic per-id PRNG (an FNV-1a-style hash of the entry `id` feeding a mulberry32 generator, same technique as this registry's other stipple components) and then sorted by distance from the brick's own center — so `visibleCount(level) = round((level/4)*16)` dots reveal outward from that brick's core as it warms, giving even a single brick a 'hot core, cool fringe' read, and the same pattern makes the WHOLE RACK read as a hot-core/cool-fringe texture once several bricks are hot at once. Every dot is `fill: var(--foreground)`; a brick's own background is a subtle `color-mix(in srgb, var(--background), var(--ns-muted) calc(var(--t)*7%))` wash (still grayscale, not a color ramp) driven by a `--t` custom property written straight onto the `<li>`. A brick that JUST got hit (T snapping toward 1, more dots turning on) or is cooling (dots turning back off) gets the SAME plain CSS `transition: opacity 220ms ease-out` on each dot — this is the entire 'single fast charge ease' the mechanism calls for: no hand-written JS ramp, just a short opacity transition doing the easing for both directions, stripped to an instant step under `prefers-reduced-motion: reduce`. LAYOUT AND ORDER: bricks render in a plain flex-wrap `<ul>`, in EXACTLY the order of the `entries` prop — the rack is never re-sorted by temperature at any point, because re-sorting would make it impossible to watch one brick's heat over time, which is the entire point of the view. When an id disappears from a later `entries` array (a real eviction happened upstream), the component doesn't unmount it immediately: it keeps the row, marks it `leaving`, and animates `max-width`/`opacity`/`margin`/`padding`/`border-width` to zero over 260ms (a small, real contraction of the rack by exactly one slot) before finally removing it from state — implemented by keeping an internal `rows` array reconciled against `entries` in a `useEffect`, with per-id `setTimeout` cleanup on unmount. ACCESSIBILITY: each brick is `<li role="listitem">` with an `aria-label` stating the full diagnostic sentence as text — `"{keyGroup}, {hitCount} hits, {forecast}"` — where `forecast` is derived purely from T and tau: `"just hit, full heat"` when T >= 0.94, `"cold, at risk of eviction now"` when T <= 0.15, otherwise `"cooling, likely evicted in ~{round(tau * ln(T/0.15))}s"` — a genuine prediction computed from the same decay constant driving the visual, not a canned string. The stipple SVG itself is `aria-hidden`. Beyond that label, every extra numeric fact (raw temperature %, TTL, time since last hit, the same forecast sentence) sits behind a real per-brick disclosure `<button data-ns-disclosure aria-expanded aria-controls>` (a '+' / '−' glyph with an sr-only accessible name, 'Show/Hide details for {keyGroup}') that reveals a `<dl data-ns-detail>` of those facts on click — never a hover-only tooltip, and this disclosure button is also what keeps the component genuinely keyboard-reachable, since a passive diagnostics grid otherwise exposes no natural control. The header renders three live stats as a `<dl>` — hit ratio, entry count, evictions/min — as plain visible text that simply re-renders on every prop change (not itself wrapped in `aria-live`, to avoid spamming assistive tech on every tick); a separate `sr-only` `role="status" aria-live="polite"` span announces a sentence ONLY when `hitRatio` crosses the caller-supplied `hitRatioAlertThreshold` in either direction ('Hit ratio dropped below 70% — cache under pressure.' / 'Hit ratio recovered above 70%.'), tracked via a ref holding the previous above/below state so re-renders at the same ratio never re-announce. TOKENS: --foreground for every dot and text value, --border for brick borders and the disclosure button's resting border, --ns-muted for secondary text and the background wash, --ns-accent ONLY on the disclosure button's `focus-visible` outline — no hex, no `rgb()`/`hsl()`, no Tailwind palette classes, no canvas. DEMO: a 12-key-group cache (`user:session`, `product:price`, `product:stock`, etc.) with short 6-45s TTLs so decay and eviction play out on screen, driven by a weighted random-hit interval (two key-groups deliberately over-weighted so they visibly thrash), a rare real eviction of whichever entry is coldest once nothing has been evicted in the last ~22s, and rolling hit-ratio/evictions-per-minute stats recomputed from a trailing event window — entirely the demo's own simulation, so the rack animates ambiently without needing any synthetic autoplay driver input.

Props

PropTypeDefaultDescription
entriesCacheEntryInput[]The tracked key-groups, in the fixed diagnostic order the rack renders them — this order is never touched by the component. An id that drops out of a later `entries` array is treated as a real eviction and animates out; a new id is appended as a new brick.
hitRationumberrolling hit ratio, 0..1, shown in the header. Caller computes this — the rack doesn't infer it from `entries`.
evictionsPerMinnumberevictions observed in the last 60s, shown in the header.
hitRatioAlertThreshold?numberif set, the sr-only live region announces only when hitRatio crosses this value (either direction).
className?stringextra classes merged onto the rendered root element