COMPONENT / telemetry-counter
Telemetry Counter_
Tabular-figure number that counts up from 0 the first time it scrolls into view. Requires @cybr/tokens.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/telemetry-counter
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
- REQ / S
- 1,284
- LATENCY MS
- 12.4
- NODES ONLINE
- 064
registry/ui/telemetry-counter.tsx
"use client";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { useReducedMotion } from "@/lib/use-reduced-motion";
import { cn } from "@/lib/utils";
function easeOutCubic(p: number) {
return 1 - Math.pow(1 - p, 3);
}
// Number readout that counts up from 0 the first time it scrolls into view.
// Screen readers get the final value from a visually hidden copy; the counting
// digits are aria-hidden. Static-first: the server, no-JS and reduced-motion
// render is the final value. A fixed default locale keeps server and client
// output identical.
export function TelemetryCounter({
value,
decimals = 0,
minDigits = 1,
locale = "en-US",
duration = 2250,
className,
...props
}: Omit<ComponentProps<"span">, "children"> & {
value: number;
decimals?: number;
minDigits?: number;
locale?: string;
duration?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const reduced = useReducedMotion();
const [seen, setSeen] = useState(false);
const [done, setDone] = useState(false);
const [current, setCurrent] = useState(0);
const animatable = Number.isFinite(value);
useEffect(() => {
const node = ref.current;
if (reduced || seen || !node) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setSeen(true);
observer.disconnect();
}
});
observer.observe(node);
return () => observer.disconnect();
}, [reduced, seen]);
useEffect(() => {
if (reduced || !seen || done || !animatable) return;
let frame = 0;
let startedAt: number | null = null;
const step = (now: number) => {
startedAt ??= now;
const p = duration > 0 ? Math.min(1, (now - startedAt) / duration) : 1;
setCurrent(value * easeOutCubic(p));
if (p < 1) frame = requestAnimationFrame(step);
else setDone(true);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [reduced, seen, done, animatable, value, duration]);
const format = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
minimumIntegerDigits: minDigits,
});
const shown = reduced || done || !animatable ? value : seen ? current : 0;
return (
<span ref={ref} className={cn("inline-block font-display tabular-nums", className)} {...props}>
<span className="sr-only">{format.format(value)}</span>
<span aria-hidden="true">{format.format(shown)}</span>
</span>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The final value. The counter animates from 0 to here. |
| decimals | number | 0 | Fraction digits, fixed for every frame so the width does not jump. |
| minDigits | number | 1 | Minimum integer digits, zero-padded (3 shows 64 as 064). |
| locale | string | "en-US" | Intl.NumberFormat locale for grouping and the decimal mark. A fixed default keeps server and client output identical. |
| duration | number | 2250 | Count length in ms, with an ease-out cubic curve. |
| className | string | — | Extra classes, merged after the defaults with cn(). |
| ...props | Omit<ComponentProps<"span">, "children"> | — | Every other native <span> attribute and ref is forwarded. |
Usage
Needs the stylesheet from @cybr/tokens, and @cybr/use-reduced-motion arrives with it. After the install, import the tokens in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
import { TelemetryCounter } from "@/components/ui/telemetry-counter";
export function Throughput() {
return <TelemetryCounter className="text-3xl" value={1284} />;
}
Behaviour
- Trigger: starts the first time it scrolls into view, then never again.
- Timing: counts from 0 to
valueoverduration(2250ms by default), easing out, and lands exactly onvalue. - Waiting: shows 0, formatted, until it is in view.
- Finished: shows
value. A later change tovalueshows the new value at once. - Static: Before hydration, without JavaScript and under
prefers-reduced-motion, it renders the final value. On the client it can show the final value for one frame before resetting to 0. - Not a number: a non-finite
value(NaN,Infinity) is formatted as-is and never animates. - Type: display font with tabular figures, so digits do not shift while counting. Set the size with
className.
Accessibility
- The final value sits in a visually hidden copy; the counting digits are
aria-hidden. Screen readers read the real number once. - The count runs once and changes digits, not colours, so it does not flash (WCAG 2.3.1).
- Under
prefers-reduced-motionit shows the final value with no count. - It is a plain number. Give it a visible label, as in the
dlabove, so the figure has meaning. - It inherits colour, so keep the text at 4.5:1 or above against its background.