Files
HealthLog/src/hooks/use-count-up.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

114 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
/**
* v1.10.0 — a tiny zero-dependency count-up animation hook for the
* score-ring / score-number "sweep to fill on first paint" affordance
* the premium-health direction calls for. No motion library — ~30 LOC of
* `requestAnimationFrame` instead, per the 0 KB runtime budget.
*
* Returns a number that eases from 0 to `target` once, on mount (and
* re-runs when `target` changes). Honours `prefers-reduced-motion`: a
* reduced-motion user (or any environment without rAF, e.g. SSR / the
* Vitest jsdom render) gets the final value immediately with no
* intermediate frames — the state is seeded to `target` so no
* synchronous-in-effect setState is needed for the no-animation path.
*/
export function useCountUp(
target: number,
options: {
durationMs?: number;
enabled?: boolean;
startDelayMs?: number;
} = {},
): number {
const { durationMs = 600, enabled = true, startDelayMs = 0 } = options;
// Seed at the final value so the reduced-motion / SSR path is already
// correct without any setState; only the animated path drives frames,
// and those setState calls all happen inside the rAF callback (async).
const [value, setValue] = useState<number>(target);
const frameRef = useRef<number | null>(null);
useEffect(() => {
const prefersReduced =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (
!enabled ||
prefersReduced ||
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function" ||
!Number.isFinite(target)
) {
// The animated path is skipped; the rAF callback below never runs,
// so the displayed value stays at whatever the last frame / seed
// left it. Schedule a single async correction in case the previous
// target differed — done via rAF so it is not a sync-in-effect set.
if (
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
frameRef.current = window.requestAnimationFrame(() => setValue(target));
return () => {
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}
return;
}
let start = 0;
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(1, elapsed / durationMs);
// ease-out cubic — matches the "never bouncy, ease-out ~300–500ms"
// motion note in the design direction.
const eased = 1 - Math.pow(1 - progress, 3);
setValue(target * eased);
if (progress < 1) {
frameRef.current = window.requestAnimationFrame(tick);
} else {
setValue(target);
}
};
// Hold the number at 0 during the stagger delay so it matches the empty
// ring, then start the rAF loop. Every setState runs inside a rAF/timeout
// callback (async) — never synchronously in the effect body — so there is
// no cascading render. `startDelayMs` lets the strip stagger each tile's
// count-up so the number trails its arc, left-to-right.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const begin = () => {
frameRef.current = window.requestAnimationFrame((now) => {
start = now;
tick(now);
});
};
const arm = window.requestAnimationFrame(() => {
setValue(0);
if (startDelayMs > 0) {
timeoutId = setTimeout(begin, startDelayMs);
} else {
begin();
}
});
return () => {
window.cancelAnimationFrame(arm);
if (timeoutId != null) clearTimeout(timeoutId);
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [target, durationMs, enabled, startDelayMs]);
return value;
}