Skip to content
Andrew SmithSolutions
← Component library

Scroll reveal

Motion

Content that settles into place as it enters the viewport, driven by a CSS scroll timeline rather than an observer.

Scroll-drivenReduced motionProgressive enhancementCSS only — no dependency

Preview

Scroll this panel

Booking
Memberships
Consent
Expenses
Reconciliation
Reporting

End of panel.

Controls

Notes

Uses animation-timeline: view(), so the browser drives it off the scroller. No IntersectionObserver, no state, nothing to run on the main thread.

Where view timelines are unsupported the content is simply visible. The fallback is the finished state, never the hidden one — a reveal that fails closed hides your page.

Wrapped in prefers-reduced-motion: no-preference. Motion that ignores that setting is not a flourish, it is an accessibility defect.

Source

Show
import { cn } from "@/lib/cn";

/**
 * The browser drives this off the scroller via a view() timeline — no
 * IntersectionObserver, no state, nothing on the main thread.
 *
 * Note what the base class does NOT do: hide anything. Where view timelines
 * are unsupported, or the reader asked for reduced motion, the content is
 * simply already in its finished state. A reveal that fails closed hides your
 * page from the people whose browsers you were not testing in.
 */
const css = `
@keyframes reveal-in {
  from {
    opacity: var(--reveal-opacity, 0);
    translate: var(--reveal-x, 0) var(--reveal-y, 0);
  }
  to { opacity: 1; translate: 0 0; }
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .reveal {
      animation: reveal-in linear both;
      animation-timeline: view();
      animation-range: entry 5% cover 32%;
    }
  }
}
`;

const distances = { sm: "12px", md: "28px", lg: "56px" } as const;

export type RevealDirection = "up" | "down" | "left" | "right";
export type RevealDistance = keyof typeof distances;

export function Reveal({
  direction = "up",
  distance = "md",
  fade = true,
  className,
  children,
}: {
  direction?: RevealDirection;
  distance?: RevealDistance;
  fade?: boolean;
  className?: string;
  children: React.ReactNode;
}) {
  const d = distances[distance];
  // Record<string, string>, not CSSProperties — custom properties are not in
  // React's style typing. The whole object is cast once at the style prop.
  const offset: Record<string, string> =
    direction === "up"
      ? { "--reveal-y": d }
      : direction === "down"
        ? { "--reveal-y": `-${d}` }
        : direction === "left"
          ? { "--reveal-x": d }
          : { "--reveal-x": `-${d}` };

  return (
    <>
      {/* React 19 hoists this and dedupes on href, so N instances ship one copy. */}
      <style href="reveal-in" precedence="default">
        {css}
      </style>
      <div
        className={cn("reveal", className)}
        style={{ ...offset, "--reveal-opacity": fade ? 0 : 1 } as React.CSSProperties}
      >
        {children}
      </div>
    </>
  );
}

export default function Demo({
  direction = "up",
  distance = "md",
  fade = true,
}: {
  direction?: RevealDirection;
  distance?: RevealDistance;
  fade?: boolean;
}) {
  return (
    // Its own scrollport, so the effect is testable here rather than only
    // once the component is on a long page.
    <div className="h-72 w-full max-w-md overflow-y-auto rounded-lg border border-rule bg-paper-sunken p-6">
      <p className="eyebrow">Scroll this panel</p>
      <div className="mt-6 space-y-5">
        {["Booking", "Memberships", "Consent", "Expenses", "Reconciliation", "Reporting"].map(
          (item) => (
            <Reveal key={item} direction={direction} distance={distance} fade={fade}>
              <div className="rounded-md border border-rule bg-paper-raised px-5 py-4 text-[15px] text-ink">
                {item}
              </div>
            </Reveal>
          ),
        )}
      </div>
      <p className="mt-6 text-[13px] text-ink-faint">End of panel.</p>
    </div>
  );
}

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.