# Scroll Expand (Moonarc)

An image that starts narrow and rounded and opens to the full width as it scrolls into view. The layout never changes: the element is full width from the start and a clip-path inset with its rounding shrinks it, animated on the element's own view() timeline; the content inside scales up a touch for depth. Without scroll timelines it is simply full width. Zero JavaScript.

- Import: `import ScrollExpand from '@moonarc/core/ScrollExpand'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/scroll-expand.json`
- Tier A · category scroll · trigger scroll
- Readout: `<ScrollExpand from={0.6} radius="2rem">`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: full width, static (Firefox)
- Measured cost: 0 B JS (CSS 4.9 kB raw)
- Page: https://moonarc.dev/components/scroll-expand/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `from` | `number` | `0.6` | Starting width as a fraction of the full width, 0.2–1. |
| `radius` | `string` | `'2rem'` | Starting corner radius. |
| `radiusEnd` | `string` | `'0px'` | Corner radius at full width. |
| `scale` | `number` | `0.92` | Starting scale of the content inside. |
| `range` | `string` | `'entry 0% cover 45%'` | animation-range on the element's view timeline. |

## Usage

```astro
<ScrollExpand from={0.6} radius="2rem">
  <img src="/case-study.jpg" alt="The studio" width="1600" height="900" />
</ScrollExpand>
```

## Reduced motion

Full width from the start; no clip, no scale.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- clip-path, not width: a width change reflows the page on every scroll frame; a clip is a compositor property and the neighbours never move.
- The corner radius rides on the same inset (inset(… round r)), so the rounding eases away as the edges reach the viewport: one keyframe, no second animation.
- The inner scale runs the opposite way to the clip (content grows as the frame opens), which reads as a camera pulling back rather than a curtain.
- The default range ends at cover 45 %: full width is reached while the image is still in the lower half of the viewport, so the reader never scrolls past a half-open frame.

## Replaces

- ScrollExpandMedia (Motion Primitives)
- ContainerScroll (Aceternity)
- scroll zoom hero (Codrops)

## Source

```astro
---
/**
 * ScrollExpand — an image that starts narrow and rounded and opens to the
 * full width as it scrolls into view, zero JS. Nothing about the layout
 * changes: the element is full width from the start and a clip-path inset
 * (with its rounding) shrinks it, animated on the element's own view()
 * timeline; the content underneath scales up a touch for depth. Without
 * scroll timelines, and under reduced motion, it is simply full width.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  as?: HTMLTag;
  /** Starting width as a fraction of the full width, 0.2–1. */
  from?: number;
  /** Starting corner radius, any CSS length; it eases to `radiusEnd`. */
  radius?: string;
  radiusEnd?: string;
  /** Starting scale of the content inside. */
  scale?: number;
  /** animation-range on the element's view timeline. */
  range?: string;
}

const { as: Tag = 'div', from = 0.6, radius = '2rem', radiusEnd = '0px', scale = 0.92, range = 'entry 0% cover 45%', class: className, style, ...rest } = Astro.props;
const inset = ((1 - Math.max(0.2, Math.min(from, 1))) / 2) * 100;
const vars = [`--ma-expand-inset:${inset}%`, `--ma-expand-r:${radius}`, `--ma-expand-r-end:${radiusEnd}`, `--ma-expand-scale:${scale}`, `--ma-expand-range:${range}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-expand', className]} style={vars} {...rest}>
  <div class="ma-expand__inner"><slot /></div>
</Tag>

<style is:global>
  @layer components {
    :where(.ma-expand) {
      display: block;
      overflow: clip;
    }
    :where(.ma-expand__inner) {
      display: block;
      transform-origin: 50% 50%;
    }
    :where(.ma-expand__inner > img),
    :where(.ma-expand__inner > picture > img),
    :where(.ma-expand__inner > video) {
      display: block;
      width: 100%;
      height: auto;
    }
    @supports (animation-timeline: view()) {
      :where(.ma-expand) {
        animation: ma-expand linear both;
        animation-range: var(--ma-expand-range, entry 0% cover 45%);
      }
      :where(.ma-expand__inner) {
        animation: ma-expand-inner linear both;
        animation-range: var(--ma-expand-range, entry 0% cover 45%);
      }
      /* the timeline in its own rule: a minifier folds it into the animation shorthand otherwise, and the shorthand cannot carry it */
      :where(html) :where(.ma-expand),
      :where(html) :where(.ma-expand__inner) {
        animation-timeline: view();
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-expand),
      :where(.ma-expand__inner) {
        animation: none;
      }
    }
  }

  @keyframes ma-expand {
    from {
      clip-path: inset(0 var(--ma-expand-inset, 20%) round var(--ma-expand-r, 2rem));
    }
    to {
      clip-path: inset(0 0 round var(--ma-expand-r-end, 0px));
    }
  }
  @keyframes ma-expand-inner {
    from {
      scale: var(--ma-expand-scale, 0.92);
    }
    to {
      scale: 1;
    }
  }
</style>

```
