Skip to content

Components / Scroll

Sticky Reveal

Steps on the left scroll by; one visual on the right stays put and changes with them. Each step names its own view timeline, the visuals are stacked in a sticky cell, and each visual's opacity and travel are bound to its step through timeline-scope. Without scroll timelines, or in a narrow container, the steps and visuals interleave in one column so every step still meets its own visual. Zero JavaScript.

Live demo

scroll inside

Write

One component, one file.

Measure

Every byte from a real build.

Ship

Nothing to hydrate.

01
02
03

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 10.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 116 · not Firefox · Safari 26; needs scroll-timeline, timeline-scope. Elsewhere: one column: each step followed by its own visual (Firefox, and every browser below 40 rem of container width).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import StickyReveal from '@moonarc/core/StickyReveal';
---
<StickyReveal steps={[{ title: 'Write', body: 'One component, one file.' }, { title: 'Measure', body: 'Every byte from a real build.' }, { title: 'Ship', body: 'Nothing to hydrate.' }]}>
  <img slot="visual-1" src="/write.png" alt="" />
  <img slot="visual-2" src="/measure.png" alt="" />
  <img slot="visual-3" src="/ship.png" alt="" />
</StickyReveal>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/sticky-reveal.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

StickyReveal.astro
---
/**
 * StickyReveal — steps on the left scroll by, one visual on the right
 * stays put and changes with them. Zero JS: each step names its own view
 * timeline, the visuals are stacked in one sticky grid cell, and each
 * visual's opacity and travel are bound to its step's timeline through
 * timeline-scope on the container. Where scroll timelines are missing, or
 * below the layout's width, the steps and visuals interleave in one column
 * (display: contents + order), so every step still meets its own visual.
 * Text steps are props; visuals are named slots, visual-1 … visual-8.
 */
import type { HTMLAttributes } from 'astro/types';

interface Step {
  title: string;
  /** Plain text or HTML. */
  body?: string;
}

interface Props extends HTMLAttributes<'div'> {
  steps: Step[];
  /** Sticky offset from the top of the scrollport. */
  top?: string;
  /** Height of each step's slot and of the sticky visual. */
  height?: string;
  /** `auto`: two columns from 40rem of container width; `sticky`: always two columns; `stack`: always one. */
  layout?: 'auto' | 'sticky' | 'stack';
}

const { steps, top = '10vh', height = '70vh', layout = 'auto', class: className, style, ...rest } = Astro.props;
const list = steps.slice(0, 8);
const vars = [`--ma-sticky-top:${top}`, `--ma-sticky-h:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-sticky', className]} data-layout={layout} style={vars} {...rest}>
  <div class="ma-sticky__steps">
    {list.map((s, i) => (
      <div class="ma-sticky__step" style={`--ma-sticky-i:${i + 1}`}>
        <h3 class="ma-sticky__title">{s.title}</h3>
        {s.body && <p class="ma-sticky__body" set:html={s.body} />}
      </div>
    ))}
  </div>
  <div class="ma-sticky__visuals">
    {list.map((_, i) => (
      <div class="ma-sticky__visual" style={`--ma-sticky-i:${i + 1}`}>
        <slot name={`visual-${i + 1}`} />
      </div>
    ))}
  </div>
</div>

<style is:global>
  @layer components {
    /* The two tracks are declared on the root in every layout. An element never answers its own container query, so a
       layout="auto" root that switched its own columns at 40rem queried whatever container sat above it (none, as a
       rule) and never went to two columns; the rules below only switch its children, which do see it. */
    :where(.ma-sticky) {
      container-type: inline-size;
      display: grid;
      grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
      gap: 1.5rem;
      timeline-scope: --ma-sticky-1, --ma-sticky-2, --ma-sticky-3, --ma-sticky-4, --ma-sticky-5, --ma-sticky-6, --ma-sticky-7, --ma-sticky-8;
    }
    /* stack: steps and visuals interleave through order across both tracks; the columns are display: contents */
    :where(.ma-sticky__steps),
    :where(.ma-sticky__visuals) {
      display: contents;
    }
    :where(.ma-sticky__step) {
      grid-column: 1 / -1;
      order: calc(var(--ma-sticky-i, 1) * 2 - 1);
    }
    :where(.ma-sticky__visual) {
      grid-column: 1 / -1;
      order: calc(var(--ma-sticky-i, 1) * 2);
      min-height: 12rem;
    }
    :where(.ma-sticky__title) {
      margin: 0;
      font-size: 1.375em;
      font-weight: 600;
      letter-spacing: -0.02em;
      line-height: 1.2;
    }
    :where(.ma-sticky__body) {
      margin: 0.5em 0 0;
      opacity: 0.7;
    }
    :where(.ma-sticky__visual > :first-child) {
      width: 100%;
      height: 100%;
    }

    /* sticky: two columns, visuals stacked in one sticky cell, each bound to its step's passage */
    @supports (animation-timeline: view()) and (timeline-scope: --a) {
      @container (min-width: 40rem) {
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__steps),
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visuals) {
          display: grid;
          align-self: start;
        }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step) {
          order: 0;
          min-height: var(--ma-sticky-h, 70vh);
          display: grid;
          align-content: center;
          animation: ma-sticky-step linear both;
          animation-range: cover 0% cover 100%;
        }
        :where(html) :where(.ma-sticky[data-layout='auto'] .ma-sticky__step) {
          animation-timeline: view();
        }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(1)) { view-timeline-name: --ma-sticky-1; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(2)) { view-timeline-name: --ma-sticky-2; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(3)) { view-timeline-name: --ma-sticky-3; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(4)) { view-timeline-name: --ma-sticky-4; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(5)) { view-timeline-name: --ma-sticky-5; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(6)) { view-timeline-name: --ma-sticky-6; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(7)) { view-timeline-name: --ma-sticky-7; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__step:nth-child(8)) { view-timeline-name: --ma-sticky-8; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visuals) {
          position: sticky;
          top: var(--ma-sticky-top, 10vh);
          height: var(--ma-sticky-h, 70vh);
        }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual) {
          order: 0;
          grid-area: 1 / 1;
          min-height: 0;
          animation: ma-sticky-visual linear both;
          animation-range: cover 0% cover 100%;
        }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:first-child) { animation-name: ma-sticky-visual-first; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:last-child) { animation-name: ma-sticky-visual-last; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:only-child) { animation-name: none; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(1)) { animation-timeline: --ma-sticky-1; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(2)) { animation-timeline: --ma-sticky-2; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(3)) { animation-timeline: --ma-sticky-3; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(4)) { animation-timeline: --ma-sticky-4; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(5)) { animation-timeline: --ma-sticky-5; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(6)) { animation-timeline: --ma-sticky-6; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(7)) { animation-timeline: --ma-sticky-7; }
        :where(.ma-sticky[data-layout='auto'] .ma-sticky__visual:nth-child(8)) { animation-timeline: --ma-sticky-8; }
      }
      /* forced: the same rules without the width condition */
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__steps),
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visuals) {
        display: grid;
        align-self: start;
      }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step) {
        order: 0;
        min-height: var(--ma-sticky-h, 70vh);
        display: grid;
        align-content: center;
        animation: ma-sticky-step linear both;
        animation-range: cover 0% cover 100%;
      }
      :where(html) :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step) {
        animation-timeline: view();
      }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(1)) { view-timeline-name: --ma-sticky-1; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(2)) { view-timeline-name: --ma-sticky-2; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(3)) { view-timeline-name: --ma-sticky-3; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(4)) { view-timeline-name: --ma-sticky-4; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(5)) { view-timeline-name: --ma-sticky-5; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(6)) { view-timeline-name: --ma-sticky-6; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(7)) { view-timeline-name: --ma-sticky-7; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__step:nth-child(8)) { view-timeline-name: --ma-sticky-8; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visuals) {
        position: sticky;
        top: var(--ma-sticky-top, 10vh);
        height: var(--ma-sticky-h, 70vh);
      }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual) {
        order: 0;
        grid-area: 1 / 1;
        min-height: 0;
        animation: ma-sticky-visual linear both;
        animation-range: cover 0% cover 100%;
      }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:first-child) { animation-name: ma-sticky-visual-first; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:last-child) { animation-name: ma-sticky-visual-last; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:only-child) { animation-name: none; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(1)) { animation-timeline: --ma-sticky-1; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(2)) { animation-timeline: --ma-sticky-2; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(3)) { animation-timeline: --ma-sticky-3; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(4)) { animation-timeline: --ma-sticky-4; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(5)) { animation-timeline: --ma-sticky-5; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(6)) { animation-timeline: --ma-sticky-6; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(7)) { animation-timeline: --ma-sticky-7; }
      :where(.ma-sticky[data-layout='sticky'] .ma-sticky__visual:nth-child(8)) { animation-timeline: --ma-sticky-8; }
    }
    /* Reduced motion: the visuals still hand over with their steps, in place (no travel), and the step text stays at
       full strength. A stepped cut used to share its keyframes with the steps, so every step but the current one was
       invisible, and it left scroll positions where no visual showed at all. */
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-sticky) {
        --ma-travel-section: 0px;
      }
      :where(.ma-sticky__step) {
        animation: none;
      }
    }
  }

  /* the visual of the step in the middle of the scrollport is the one that shows */
  @keyframes ma-sticky-visual {
    0% {
      opacity: 0;
      translate: 0 var(--ma-travel-section);
    }
    30%,
    70% {
      opacity: 1;
      translate: 0 0;
    }
    100% {
      opacity: 0;
      translate: 0 calc(-1 * var(--ma-travel-section));
    }
  }
  @keyframes ma-sticky-visual-first {
    0%,
    70% {
      opacity: 1;
      translate: 0 0;
    }
    100% {
      opacity: 0;
      translate: 0 calc(-1 * var(--ma-travel-section));
    }
  }
  @keyframes ma-sticky-visual-last {
    0% {
      opacity: 0;
      translate: 0 var(--ma-travel-section);
    }
    30%,
    100% {
      opacity: 1;
      translate: 0 0;
    }
  }
  @keyframes ma-sticky-step {
    0%,
    100% {
      opacity: 0.35;
    }
    35%,
    65% {
      opacity: 1;
    }
  }
</style>

Props

PropTypeDefaultDescription
steps{ title: string; body?: string }[]noneUp to eight steps; body may be HTML.
topstring'10vh'Sticky offset from the top of the scrollport.
heightstring'70vh'Height of each step's slot and of the sticky visual.
layout'auto' | 'sticky' | 'stack''auto'auto: two columns from 40 rem of container width; sticky: always; stack: never.

Reduced motion

The visuals still hand over with their steps, crossfading in place with no travel, and the step text no longer dims.

With ClientRouter

CSS-only; the timelines are recomputed with the new page.

Why it is built this way

Replaces: StickyScrollReveal (Aceternity) · sticky scroll section (Tailwind Plus) · ScrollStack (React Bits). See the migration table.

Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.

Chrome 116 · not Firefox · Safari 26scroll

A strip of items slides sideways while the page scrolls down and the section stays pinned.

Chrome 115 · not Firefox · Safari 26scroll

Bind translate, scale, rotate and opacity to the scroll position: the element goes from the start values you give to neutral across a range of its own passage through the viewport, exactly in step.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown