# Sticky Reveal (Moonarc)

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.

- Import: `import StickyReveal from '@moonarc/core/StickyReveal'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/sticky-reveal.json`
- Tier A · category scroll · trigger scroll
- Readout: `<StickyReveal steps={[…]} top="10vh">`
- Browser support: limited (Chrome 116 · not Firefox · Safari 26); elsewhere: one column: each step followed by its own visual (Firefox, and every browser below 40 rem of container width)
- Measured cost: 0 B JS (CSS 10.6 kB raw)
- Page: https://moonarc.dev/components/sticky-reveal/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `steps` | `{ title: string; body?: string }[]` | none | Up to eight steps; body may be HTML. |
| `top` | `string` | `'10vh'` | Sticky offset from the top of the scrollport. |
| `height` | `string` | `'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. |

## Usage

```astro
<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>
```

## 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.

## Craft

- timeline-scope on the container lets a visual read a sibling column's timeline: the visual for step 3 is literally animated by step 3's passage, so they can never drift apart.
- A visual is fully on from 30 % to 70 % of its step's cover range and hands over in the outer 30 %s; the first and last visuals hold at the ends so the section never starts or ends empty.
- The stacked fallback is not "the first visual only": display: contents on both columns and an order per item interleave the DOM into step, visual, step, visual, which is what a phone should show anyway.
- The width switch is a container query, not a media query, so a StickyReveal in a sidebar stacks while one across the page sticks. The component is its own container, and an element never answers its own query, so the two tracks are declared on it in every layout and only its children switch.

## Replaces

- StickyScrollReveal (Aceternity)
- sticky scroll section (Tailwind Plus)
- ScrollStack (React Bits)

## Source

```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>

```
