hooks
Scroll and motion hooks: useReducedMotion, useScrollspy, useReadProgress, useInView, plus smoothScrollTo.
hooksscrollmotionutility
Preview
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";
const SECTIONS = ["one", "two", "three"];
export default function HooksDemo() {
const active = useScrollspy(SECTIONS);
const progress = useReadProgress("#demo-article");
const ref = React.useRef<HTMLParagraphElement>(null);
const seen = useInView(ref);
return (
<div style={{ fontFamily: "var(--moco-serif)", color: "var(--moco-body)" }}>
<div
style={{
position: "sticky",
top: 0,
display: "flex",
gap: "1rem",
alignItems: "baseline",
padding: "0.6rem 0",
background: "var(--moco-bg)",
borderBottom: "1px solid var(--moco-line)",
fontFamily: "var(--moco-mono)",
fontSize: "0.85rem",
}}
>
<span style={{ color: "var(--moco-muted)" }}>
read: {Math.round(progress * 100)}%
</span>
{SECTIONS.map((id, i) => (
<button
key={id}
onClick={() => smoothScrollTo(id)}
style={{
font: "inherit",
border: 0,
background: "none",
padding: 0,
cursor: "pointer",
color: active === i ? "var(--moco-ink)" : "var(--moco-faint)",
fontWeight: active === i ? 700 : 400,
}}
>
{id}
</button>
))}
</div>
<article id="demo-article">
{SECTIONS.map((id) => (
<section key={id} id={id} style={{ minHeight: "60vh", paddingTop: "1.5rem" }}>
<h2 style={{ fontFamily: "var(--moco-display)", fontWeight: 400, color: "var(--moco-ink)" }}>{id}</h2>
<p style={{ maxWidth: "38rem" }}>
Scroll — the bar above tracks read progress through the article,
and the section names bold as their heading crosses the viewport.
</p>
</section>
))}
<p ref={ref} style={{ fontFamily: "var(--moco-mono)", fontSize: "0.85rem" }}>
{seen ? "useInView: 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 });
}