Reveal on Scroll

A vertical timeline whose entries fade, rise and un-rotate into place as they cross into view — each one self-timed by a per-element view() timeline, so they stagger naturally. A slim rail on the side tracks overall scroll progress. No JS.

  • animation-timeline: view()
  • animation-range: entry
  • scroll() progress

Source

A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.

scroll/reveal-stagger.astro
---
export const meta = {
  title: "Reveal on Scroll",
  tags: ["scroll", "reveal", "timeline"],
  description:
    "A vertical timeline whose entries fade, rise and un-rotate into place as they cross into view — each one self-timed by a per-element view() timeline, so they stagger naturally. A slim rail on the side tracks overall scroll progress. No JS.",
  tech: ["animation-timeline: view()", "animation-range: entry", "scroll() progress"],
  height: 600,
};

const items = [
  { time: "0.0s", title: "Signal acquired", body: "A single pointer event enters the pipeline and is stamped with a monotonic clock." },
  { time: "0.3s", title: "Intent resolved", body: "Gestures are classified against the last few frames of motion history." },
  { time: "0.9s", title: "State reconciled", body: "The optimistic update is merged and broadcast to every connected peer." },
  { time: "1.4s", title: "Layout committed", body: "Only the dirty region repaints; everything else stays on the compositor." },
  { time: "2.1s", title: "Motion settled", body: "Springs relax to rest and the frame budget is handed back to the browser." },
  { time: "2.8s", title: "Idle reached", body: "Work drains, listeners quiesce, and the surface waits for the next signal." },
];
---

<section class="reveal">
  <div class="reveal__rail" aria-hidden="true">
    <span class="reveal__rail-fill"></span>
  </div>

  <div class="reveal__body">
    <header class="reveal__intro">
      <p class="reveal__eyebrow">Frame lifecycle</p>
      <h2>Everything, in order.</h2>
    </header>

    <ol class="reveal__list">
      {items.map((it) => (
        <li class="reveal__item">
          <span class="reveal__dot" aria-hidden="true"></span>
          <span class="reveal__time">{it.time}</span>
          <div class="reveal__card">
            <h3>{it.title}</h3>
            <p>{it.body}</p>
          </div>
        </li>
      ))}
    </ol>
  </div>
</section>

<style>
  .reveal {
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr;
    gap: clamp(1rem, 3vw, 2rem);
    padding: clamp(2rem, 6vw, 4rem) clamp(1.25rem, 5vw, 3.5rem);
    background: var(--bg, #0e0f1a);
    color: var(--ink, #f2f2f7);
  }

  /* progress rail */
  .reveal__rail {
    position: sticky;
    top: clamp(2rem, 6vw, 4rem);
    align-self: start;
    width: 4px;
    height: clamp(14rem, 60vh, 24rem);
    border-radius: var(--radius-round, 999px);
    background: var(--border, rgba(255,255,255,0.12));
    overflow: hidden;
  }
  .reveal__rail-fill {
    display: block;
    width: 100%;
    height: 100%;
    transform-origin: top center;
    transform: scaleY(0);
    border-radius: inherit;
    background: linear-gradient(
      var(--spectrum-1, #8b5cf6),
      var(--spectrum-3, #45d3e8),
      var(--spectrum-5, #fbbf24)
    );
  }

  .reveal__body { min-width: 0; }
  .reveal__intro { margin-bottom: clamp(1.5rem, 5vw, 3rem); }
  .reveal__eyebrow {
    margin: 0 0 0.4rem;
    font-family: var(--font-mono, monospace);
    font-size: var(--step--1, 0.85rem);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--spectrum-3, #45d3e8);
  }
  .reveal__intro h2 {
    margin: 0;
    font-size: var(--step-5, 2.8rem);
    line-height: 1.03;
    letter-spacing: -0.03em;
    text-wrap: balance;
  }

  .reveal__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: clamp(1.25rem, 3vw, 2rem);
  }
  .reveal__item {
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: start;
    gap: 0.75rem 1rem;
    padding-left: 1.25rem;
  }
  /* connecting line */
  .reveal__item::before {
    content: "";
    position: absolute;
    left: 0.28rem;
    top: 0.9rem;
    bottom: -1.4rem;
    width: 2px;
    background: linear-gradient(var(--border-strong, rgba(255,255,255,0.2)), transparent);
  }
  .reveal__item:last-child::before { display: none; }
  .reveal__dot {
    grid-row: 1;
    width: 0.7rem;
    height: 0.7rem;
    margin-top: 0.35rem;
    border-radius: 50%;
    background: var(--spectrum-3, #45d3e8);
    box-shadow: 0 0 0 4px color-mix(in oklab, var(--spectrum-3, #45d3e8) 22%, transparent);
  }
  .reveal__time {
    grid-row: 1;
    font-family: var(--font-mono, monospace);
    font-size: var(--step--1, 0.85rem);
    color: var(--ink-faint, #8a8c9e);
    padding-top: 0.15rem;
  }
  .reveal__card {
    grid-column: 2;
    background: var(--surface, #14151f);
    border: 1px solid var(--border, rgba(255,255,255,0.1));
    border-radius: var(--radius-m, 14px);
    padding: clamp(0.9rem, 2vw, 1.35rem);
    box-shadow: var(--shadow-m, 0 8px 24px rgba(0,0,0,0.25));
  }
  .reveal__card h3 {
    margin: 0 0 0.35rem;
    font-size: var(--step-1, 1.3rem);
    letter-spacing: -0.02em;
  }
  .reveal__card p {
    margin: 0;
    line-height: 1.55;
    color: var(--ink-muted, #b7b9c9);
  }

  /* ---- Enhanced: scroll-driven reveal + progress ---- */
  @supports (animation-timeline: view()) {
    .reveal__item {
      animation: reveal-in linear both;
      animation-timeline: view();
      animation-range: entry 5% entry 60%;
    }
    .reveal__rail-fill {
      animation: reveal-progress linear both;
      animation-timeline: scroll(nearest block);
    }
  }
  @keyframes reveal-in {
    from {
      opacity: 0;
      transform: translateY(42px) rotate(-2.5deg) scale(0.97);
      filter: blur(4px);
    }
    to {
      opacity: 1;
      transform: none;
      filter: blur(0);
    }
  }
  @keyframes reveal-progress {
    from { transform: scaleY(0); }
    to { transform: scaleY(1); }
  }

  @media (prefers-reduced-motion: reduce) {
    @supports (animation-timeline: view()) {
      .reveal__item { animation: none; opacity: 1; transform: none; filter: none; }
      .reveal__rail-fill { animation: none; transform: scaleY(1); }
    }
  }
</style>