Skip to content
Twenty One Media
webSeptember 11, 2026

Scroll Reveals Without the Library

The homepage has been through two scroll-reveal systems in the past two weeks.

The first used Framer Motion: AnimatedReveal with whileInView, initial, and animate props. It had bugs. Five of the six reveal wrappers never animated because Framer doesn't propagate staggerChildren to plain DOM children. Then reading useReducedMotion() at render time produced different HTML on the server versus the client, triggering React's hydration error. Both bugs took real fixes.

For the editorial redesign, we needed reveals on about 30 elements across a fresh page. We wrote something different.

export function StudioReveal({ children, className = "" }) {
  const ref = useRef(null);
  useEffect(() => {
    const element = ref.current;
    if (!element || !window.IntersectionObserver) return;
    const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
    if (preference.matches) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          element.classList.add("studio-entered");
          observer.disconnect();
        }
      },
      { threshold: 0.08 }
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  return <div ref={ref} className={`studio-reveal ${className}`}>{children}</div>;
}

That's the whole component.

No hydration mismatch. The server renders a plain div. It has no idea this element will animate. useEffect runs client-side only, after hydration, so the SSR and CSR outputs are identical. No useSyncExternalStore. No useHydrated guard. No conditional render that differs between server and client.

Progressive enhancement by default. Content inside .studio-reveal is visible by default. If JavaScript doesn't run, users see the content. If IntersectionObserver is missing (the guard on the first line of the effect), users see the content. If the user prefers reduced motion, we return early inside the useEffect, after hydration, with no mismatch possible.

The CSS handles the actual animation:

.studio-reveal {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.55s var(--ease-out), translate 0.55s var(--ease-out);
}

@media (prefers-reduced-motion: no-preference) {
  .studio-reveal:not(.studio-entered) {
    opacity: 0;
    translate: 0 1.5rem;
  }
}

The hidden initial state lives inside the prefers-reduced-motion: no-preference query. If CSS runs without JS, the element hides and stays hidden. But that's the point: we scope the hiding rule to users who have not expressed a preference for reduced motion. Those users expect motion. If they get a blank page because JS failed, that's a worse experience than a flash of un-animated content.

In practice, JS runs. The studio-entered class gets added. The CSS transition fires once.

One shot. observer.disconnect() inside the intersection callback means each element animates in exactly once and stays. The cleanup function in the return handles unmounting before the element enters view.

When not to use this. Framer Motion is the right tool for layout animations, drag interactions, spring physics, shared element transitions, and complex orchestration. For "fade and rise as you scroll to it," the Web API works, the API surface is tiny, and you can read every line of it in 15 seconds.

The StudioReveal component now wraps every section on the homepage. The page is a Server Component. The reveals are a Client Component. No hydration mismatches, no stagger propagation bugs, no extra runtime.