Chunte's picture
Chunte HF Staff
Minimal UI: nav icons + motion pass (transitions-dev + apple-design), UX unchanged
e63a744 verified
Raw History Blame Contribute Delete
2.64 kB
import { useEffect, useState } from "react";
/**
* Motion helpers shared by every surface that animates out: menus, popovers, the phone drawer,
* overlays. Durations come from the CSS tokens in globals.css so JS timers and CSS transitions
* can't drift apart, and collapse to 0 under reduced motion.
*/
export function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
/** "150ms" → 150, "0.25s" → 250; anything else → fallback. */
export function parseDurationMs(raw: string, fallback: number): number {
const m = /^(-?\d*\.?\d+)(ms|s)$/.exec(raw.trim());
if (!m) return fallback;
const n = parseFloat(m[1]);
return m[2] === "s" ? n * 1000 : n;
}
/** A duration token (e.g. "--duration-quick") from :root, in ms. */
export function readDurationMs(token: string, fallback: number): number {
if (typeof window === "undefined") return fallback;
const raw = getComputedStyle(document.documentElement).getPropertyValue(
token,
);
return parseDurationMs(raw, fallback);
}
/** How long to keep a closing surface mounted: its exit token, or 0 under reduced motion. */
export function exitMs(token = "--duration-quick", fallback = 150): number {
return prefersReducedMotion() ? 0 : readDurationMs(token, fallback);
}
export type PresenceState = "open" | "closing" | "closed";
/**
* Keeps a surface mounted long enough to play its exit. Render it while the state isn't
* "closed", put `data-state={state}` on it, and make it inert while "closing". Reopening during
* the exit goes straight back to "open", so CSS transitions reverse from the live on-screen value
* instead of jumping (interruptible, unlike keyframes).
*/
export function usePresence(
open: boolean,
token = "--duration-quick",
fallback = 150,
): PresenceState {
const [phase, setPhase] = useState<PresenceState>(open ? "open" : "closed");
// Derived during render (React's sanctioned pattern), so there is never a frame where an
// open surface is unmounted or a closing one has already vanished.
if (open && phase !== "open") setPhase("open");
if (!open && phase === "open") setPhase("closing");
useEffect(() => {
if (phase !== "closing") return;
const t = window.setTimeout(
() => setPhase("closed"),
exitMs(token, fallback),
);
return () => window.clearTimeout(t);
}, [phase, token, fallback]);
if (open) return "open";
return phase === "open" ? "closing" : phase;
}