Design gallery
Motion and effects

Scroll reveal

Headings rise in word by word and blocks stagger in as they scroll into view. Scroll inside the box to see each one. Visible without JavaScript.

Blocks stagger in

Each line rises 24px and fades in.

The next one waits 80ms.

Then the next.

Headings rise word by word

Words come in 45ms apart. The tiles below stagger like the lines did.

Once each, never again

Scroll back up: nothing hides again.

Content stays visible if the script never runs.

Reduced motion shows it all straight away.

Code 2 files

src/gallery/scroll-reveal/ScrollReveal.astroAstro166 lines
---
// A scrolling box of sample content: headings rise in word by word,
// blocks stagger in child by child, once each, as they enter.
// Each part is one box tall, so one effect shows at a time.
// In a grid card (inside [data-preview]) it scrolls itself on a loop.
// On a real page, call initReveals(document.body) and drop the scroll box.
---
<div class="reveal" data-reveal-root>
  <div class="scroller" tabindex="0" aria-label="Scroll reveal demo. Scroll to see each effect.">
    <section class="part">
      <h3 data-split>Blocks stagger in</h3>
      <div class="blocks" data-reveal>
        <p>Each line rises 24px and fades in.</p>
        <p>The next one waits 80ms.</p>
        <p>Then the next.</p>
      </div>
    </section>
    <section class="part">
      <h3 data-split>Headings rise word by word</h3>
      <div class="blocks" data-reveal>
        <p>Words come in 45ms apart. The tiles below stagger like the lines did.</p>
      </div>
      <div class="tiles" data-reveal>
        <div></div><div></div><div></div><div></div>
      </div>
    </section>
    <section class="part">
      <h3 data-split>Once each, never again</h3>
      <div class="blocks" data-reveal>
        <p>Scroll back up: nothing hides again.</p>
        <p>Content stays visible if the script never runs.</p>
        <p>Reduced motion shows it all straight away.</p>
      </div>
    </section>
  </div>
  <p class="hint" aria-hidden="true">
    Scroll down
    <svg viewBox="0 0 16 16"><path d="M8 3v10M4 9l4 4 4-4" /></svg>
  </p>
  <button class="replay" type="button">Replay</button>
</div>

<style>
  .reveal {
    --rv-ink: #f1f1ef; --rv-soft: #b4b5af; --rv-tile: #262626; --rv-accent: #9fd09c;
    --rv-ease: cubic-bezier(0.23, 1, 0.32, 1);
    position: absolute; inset: 0; color: var(--rv-ink);
    container-type: size;
  }
  .scroller { height: 100%; overflow-y: auto; padding: 1.5rem 1.5rem 0; scrollbar-width: thin; }
  /* Each part is one box tall, so the next effect starts out of view and scrolling brings in one at a time. */
  .part { min-height: 100cqh; }
  h3 { font-size: clamp(1.5rem, 6cqw, 3rem); font-weight: 400; letter-spacing: -0.03em; line-height: 1.05; margin: 0 0 1rem; max-width: 18ch; }
  .blocks p { color: var(--rv-soft); margin: 0 0 0.375rem; font-size: clamp(0.9375rem, 2.2cqw, 1.125rem); max-width: 44ch; }
  .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; margin-top: 1.25rem; max-width: 32rem; }
  .tiles div { aspect-ratio: 1; background: var(--rv-tile); border-radius: 4px; }
  .tiles div:first-child { background: var(--rv-accent); }

  /* Blocks: each child rises in, staggered. */
  .armed [data-reveal] > * {
    opacity: 0; transform: translateY(24px);
    transition: opacity 700ms var(--rv-ease), transform 700ms var(--rv-ease);
  }
  .armed [data-reveal].in > * { opacity: 1; transform: none; }
  .armed [data-reveal].in > *:nth-child(2) { transition-delay: 80ms; }
  .armed [data-reveal].in > *:nth-child(3) { transition-delay: 160ms; }
  .armed [data-reveal].in > *:nth-child(4) { transition-delay: 240ms; }

  /* Headings: reveal.ts wraps each word; words rise in one after another. */
  .armed [data-split] :global(.w) { display: inline-block; overflow: hidden; vertical-align: bottom; padding-bottom: 0.08em; margin-bottom: -0.08em; }
  .armed [data-split] :global(.w > span) {
    display: inline-block;
    transform: translateY(105%);
    transition: transform 800ms var(--rv-ease);
    transition-delay: calc(var(--i, 0) * 45ms);
  }
  .armed [data-split].in :global(.w > span) { transform: none; }

  /* Tells you the box scrolls; fades once you have scrolled. */
  .hint {
    position: absolute; left: 1.5rem; bottom: 0.75rem;
    display: flex; align-items: center; gap: 0.375rem; height: 2rem; margin: 0;
    color: var(--rv-soft); font-size: 0.8125rem; pointer-events: none;
    transition: opacity 240ms ease;
  }
  .hint svg { width: 0.875rem; height: 0.875rem; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; animation: nudge 1.6s var(--rv-ease) infinite; }
  .scrolled .hint, .auto .hint { opacity: 0; }

  .replay {
    position: absolute; right: 0.75rem; bottom: 0.75rem;
    height: 2rem; padding-inline: 0.875rem; border-radius: 999px;
    background: rgb(255 255 255 / 0.08); color: var(--rv-ink); font: inherit; font-size: 0.8125rem;
    cursor: pointer; transition: transform 160ms var(--rv-ease), background-color 160ms ease;
  }
  .replay:hover { background: rgb(255 255 255 / 0.14); }
  .replay:active { transform: scale(0.97); }

  @keyframes nudge { 50% { transform: translateY(3px); } }

  @media (prefers-reduced-motion: reduce) {
    .armed [data-reveal] > *, .armed [data-split] :global(.w > span) { opacity: 1; transform: none; transition: none; }
    .hint svg { animation: none; }
  }
</style>

<script>
  import { initReveals } from './reveal';

  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const SPEED = 70; // px per second while auto-scrolling
  const HOLD_TOP = 1100; // ms to wait before scrolling down
  const HOLD_END = 1600; // ms to wait at the bottom before starting again

  // In a grid card: scroll down slowly, wait, replay from the top, repeat.
  // Stops off screen and while the pointer or focus is inside, so it can be scrolled by hand.
  function autoPlay(root: HTMLElement, scroller: HTMLElement, reset: () => void) {
    root.classList.add('auto');
    let phase: 'top' | 'down' | 'end' = 'top';
    let wait = HOLD_TOP, pos = 0, last = 0, raf = 0, visible = false, held = false;

    const tick = (t: number) => {
      const dt = last ? Math.min(t - last, 50) : 0;
      last = t;
      if (phase === 'down') {
        const max = scroller.scrollHeight - scroller.clientHeight;
        pos = Math.min(max, pos + (SPEED * dt) / 1000);
        scroller.scrollTop = pos;
        if (pos >= max) { phase = 'end'; wait = HOLD_END; }
      } else if ((wait -= dt) <= 0) {
        if (phase === 'end') { reset(); pos = 0; phase = 'top'; wait = HOLD_TOP; }
        else phase = 'down';
      }
      raf = requestAnimationFrame(tick);
    };
    const run = () => {
      if (raf || !visible || held) return;
      last = 0;
      pos = scroller.scrollTop;
      raf = requestAnimationFrame(tick);
    };
    const stop = () => { cancelAnimationFrame(raf); raf = 0; };
    const hold = (on: boolean) => { held = on; on ? stop() : run(); };

    new IntersectionObserver(([e]) => {
      visible = !!e?.isIntersecting;
      visible ? run() : stop();
    }).observe(root);
    root.addEventListener('pointerenter', () => hold(true));
    root.addEventListener('pointerleave', () => hold(false));
    root.addEventListener('focusin', () => hold(true));
    root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
    root.querySelector('.replay')?.addEventListener('click', () => { pos = 0; phase = 'top'; wait = HOLD_TOP; });
  }

  document.querySelectorAll<HTMLElement>('[data-reveal-root]').forEach((root) => {
    const scroller = root.querySelector<HTMLElement>('.scroller');
    if (!scroller) return;
    const replay = initReveals(root, scroller);
    const reset = () => { root.classList.remove('scrolled'); replay(); };
    root.querySelector('.replay')?.addEventListener('click', reset);
    scroller.addEventListener('scroll', () => {
      if (scroller.scrollTop > 24) root.classList.add('scrolled');
    }, { passive: true });
    if (root.closest('[data-preview]') && !reduce) autoPlay(root, scroller, reset);
  });
</script>
src/gallery/scroll-reveal/reveal.tsTypeScript60 lines
// Scroll motion: word-split headings and staggered blocks.
// Content is always in the DOM and visible without JS; this only adds the travel.
// Pass `scroller` to watch a scrolling box instead of the page.

const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

// Wrap each word of [data-split] elements so they can rise in one after another.
function splitWords(el: HTMLElement) {
  if (el.dataset.splitDone) return;
  el.dataset.splitDone = '1';
  let i = 0;
  const walk = (node: Node) => {
    for (const child of [...node.childNodes]) {
      if (child.nodeType === Node.TEXT_NODE) {
        const parts = (child.textContent ?? '').split(/(\s+)/);
        const frag = document.createDocumentFragment();
        for (const part of parts) {
          if (!part) continue;
          if (/^\s+$/.test(part)) { frag.append(part); continue; }
          const w = document.createElement('span');
          w.className = 'w';
          const inner = document.createElement('span');
          inner.style.setProperty('--i', String(i++));
          inner.textContent = part;
          w.append(inner);
          frag.append(w);
        }
        child.replaceWith(frag);
      } else if (child.nodeType === Node.ELEMENT_NODE && !(child as HTMLElement).matches('br')) {
        walk(child);
      }
    }
  };
  walk(el);
}

export function initReveals(root: HTMLElement, scroller: HTMLElement | null = null) {
  root.querySelectorAll<HTMLElement>('[data-split]').forEach(splitWords);
  const targets = root.querySelectorAll<HTMLElement>('[data-reveal], [data-split]');
  if (reduce) { targets.forEach((t) => t.classList.add('in')); return () => {}; }
  root.classList.add('armed'); // hide-until-revealed styles only apply once JS has run
  const io = new IntersectionObserver(
    (entries) => {
      for (const e of entries) {
        if (!e.isIntersecting) continue;
        e.target.classList.add('in');
        io.unobserve(e.target);
      }
    },
    { root: scroller, rootMargin: '0px 0px -12% 0px' }
  );
  targets.forEach((t) => io.observe(t));

  // Replay: hide everything again and watch from the top.
  return () => {
    targets.forEach((t) => { t.classList.remove('in'); io.unobserve(t); });
    if (scroller) scroller.scrollTop = 0;
    requestAnimationFrame(() => requestAnimationFrame(() => targets.forEach((t) => io.observe(t))));
  };
}