Documentation menu

hooks

Scroll and motion hooks: useReducedMotion, useScrollspy, useReadProgress, useInView, plus smoothScrollTo.

hooksscrollmotionutility

Preview

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco hooks component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/hooks.json

Manual

Copy each file from the Source section into its target path:

registry/hooks/hooks.ts  →  components/moco/hooks.ts

Usage

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 });
}