Files
hwilliams-dev/hooks/useAnimations.ts

129 lines
3.8 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, type CSSProperties } from "react";
export function useStaggerReveal(count: number, baseDelay = 80) {
const [visible, setVisible] = useState<Set<number>>(new Set());
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
for (let i = 0; i < count; i++) {
timers.push(
setTimeout(
() => setVisible((prev) => new Set(prev).add(i)),
baseDelay * (i + 1) + 200
)
);
}
return () => timers.forEach(clearTimeout);
}, [count, baseDelay]);
return visible;
}
/**
* Tracks the rendered width of an element. `mounted` stays false until the
* first measurement lands, so callers can avoid rendering a width-dependent
* layout against a zero/SSR width.
*/
export function useElementWidth<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
const [mounted, setMounted] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 0;
setWidth(w);
setMounted(true);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { width, ref, mounted };
}
/**
* Lets a nested element break out to the full width of the viewport.
*
* The pure-CSS `left:50%; margin-left:-50vw` trick only works when every
* ancestor is horizontally centered, which is not true here -- Profile's
* content column is left-aligned inside the centered page wrapper. Measuring
* the real offset is exact from any nesting depth, and using
* `documentElement.clientWidth` instead of `100vw` excludes the scrollbar,
* so this can't induce a horizontal scroll.
*
* Returns a style that is a no-op until the first measurement lands.
*/
export function useFullBleed<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const [bleed, setBleed] = useState<{ width: number; offsetLeft: number } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const width = document.documentElement.clientWidth;
// Undo any margin this element is already carrying, so re-measuring is idempotent.
const current = parseFloat(el.style.marginLeft || "0") || 0;
const offsetLeft = el.getBoundingClientRect().left - current;
setBleed((prev) =>
prev && prev.width === width && Math.abs(prev.offsetLeft - offsetLeft) < 0.5
? prev
: { width, offsetLeft }
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(document.documentElement);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, []);
const style: CSSProperties = bleed
? { width: bleed.width, marginLeft: -bleed.offsetLeft }
: { width: "100%", marginLeft: 0 };
return { ref, style, measured: bleed !== null };
}
/** Mirrors the user's `prefers-reduced-motion` setting, and follows changes to it. */
export function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduced(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return reduced;
}
export function useMountTransition(delay = 50) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
const t = setTimeout(() => setMounted(true), delay);
return () => clearTimeout(t);
}, [delay]);
return mounted;
}