A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.
Scroll Expand
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.
Live demo
scroll inside
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
CSS 4.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 115 · not Firefox · Safari 26; needs scroll-timeline, clip-path. Elsewhere: full width, static (Firefox).
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import ScrollExpand from '@moonarc/core/ScrollExpand';
---
<ScrollExpand from={0.6} radius="2rem">
<img src="/case-study.jpg" alt="The studio" width="1600" height="900" />
</ScrollExpand>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/scroll-expand.jsonThe 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.
---
/**
* 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>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. |
Reduced motion
Full width from the start; no clip, no scale.
With ClientRouter
CSS-only; nothing to rebind.
Why it is built this way
- 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). See the migration table.
Related
Parallax
ScrollAn element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.
Scroll Scrub
ScrollBind 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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown