hooks
Scroll and motion hooks: useReducedMotion, useScrollspy, useReadProgress, useInView, plus smoothScrollTo.
hooksscrollmotionutility
Preview
read: 0%
one
two
three
Scroll down…
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/hooks.jsonManual
Copy each file from the Source section into its target path:
registry/hooks/hooks.ts → components/moco/hooks.tsUsage
usage.tsx
"use client";
import * as React from "react";
import { useInView, useReadProgress, useScrollspy, smoothScrollTo } from "./hooks";
export default function HooksDemo() {
const sections = ["one", "two", "three"];
const active = useScrollspy(sections);
const progress = useReadProgress("#demo-article");
const ref = React.useRef<HTMLParagraphElement>(null);
const seen = useInView(ref);
return (
<div>
<div style={{ position: "sticky", top: 0, display: "flex", gap: "1rem" }}>
<span>read: {Math.round(progress * 100)}%</span>
{sections.map((id, i) => (
<button key={id} onClick={() => smoothScrollTo(id)} style={{ fontWeight: active === i ? 700 : 400 }}>
{id}
</button>
))}
</div>
<article id="demo-article">
{sections.map((id) => (
<section key={id} id={id} style={{ minHeight: "80vh" }}>
<h2>{id}</h2>
</section>
))}
<p ref={ref}>{seen ? "You scrolled me into view." : "Scroll down…"}</p>
</article>
</div>
);
}
Source
components/moco/hooks.ts
"use client";
import * as React from "react";
export function useReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const on = () => setReduced(mq.matches);
on();
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, []);
return reduced;
}
/** Index of the section currently crossing the middle band of the viewport. */
export function useScrollspy(ids: string[]): number {
const [active, setActive] = React.useState(0);
React.useEffect(() => {
if (!ids.length) return;
const seen = new Map<string, number>();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) seen.set(e.target.id, e.intersectionRatio);
// The last heading whose top has passed the band wins.
let next = 0;
ids.forEach((id, i) => {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= window.innerHeight * 0.35) next = i;
});
setActive(next);
},
{ rootMargin: "-30% 0px -60% 0px", threshold: [0, 0.5, 1] },
);
const els = ids.map((id) => document.getElementById(id)).filter(Boolean) as Element[];
els.forEach((el) => io.observe(el));
const onScroll = () => {
let next = 0;
ids.forEach((id, i) => {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= window.innerHeight * 0.35) next = i;
});
setActive(next);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
};
}, [ids]);
return active;
}
/** 0→1 read progress through the first element matching the selector. */
export function useReadProgress(selector = "#article-root"): number {
const [p, setP] = React.useState(0);
React.useEffect(() => {
const onScroll = () => {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return;
const start = el.offsetTop;
const end = start + el.offsetHeight - window.innerHeight;
const y = window.scrollY;
const raw = end > start ? (y - start) / (end - start) : 0;
setP(Math.min(1, Math.max(0, raw)));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [selector]);
return p;
}
/** Fires once when the element first enters the viewport. */
export function useInView<T extends Element>(
ref: React.RefObject<T | null>,
{ once = true, rootMargin = "0px 0px -15% 0px" } = {},
): boolean {
const [seen, setSeen] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
setSeen(true);
if (once) io.disconnect();
} else if (!once) {
setSeen(false);
}
},
{ rootMargin },
);
io.observe(el);
return () => io.disconnect();
}, [ref, once, rootMargin]);
return seen;
}
export function smoothScrollTo(id: string) {
const el = document.getElementById(id);
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
el.setAttribute("tabindex", "-1");
(el as HTMLElement).focus({ preventScroll: true });
}