Scroll-Driven Media & Gallery Effects

Photography, video posters and card galleries are where scroll-driven animation is most visible and most easily overdone. The techniques are the same handful of compositor-safe properties used elsewhere in this section, but the constraints are different: images are large, decoding is expensive, and the visual weight of a full-bleed photograph makes displacement that would be unremarkable on a text block genuinely uncomfortable. This topic covers the media-specific patterns and the media-specific costs.

Guides in this topic

The media effect families Five families arranged by progress source and animated property. Image reveals and pans read a subject view timeline and animate clip-path or transform. Horizontal galleries read the scroller inline axis. Stacking cards combine sticky positioning with a view timeline. Snap-coupled effects read the same scroller as the snap points. progress source on the left, animated property on the right Image reveal view(block) clip-path Ken Burns pan view(block) scale + translate Horizontal gallery scroll(inline) transform + opacity Stacking cards view + sticky scale + translate Snap-coupled scroll(inline) opacity + filter

Syntax reference

Media effects use the same timeline surface as everything else; what differs is which properties are worth animating on a large raster.

/* Directional reveal β€” clip-path is compositor-safe and does not repaint the image */
.figure img {
  clip-path: inset(0 100% 0 0);
  animation: wipe linear both;
  animation-timeline: view(block);
  animation-range: entry 20% cover 45%;
}
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }

/* Slow pan β€” scale and translate, never width/height or background-position */
.hero img {
  animation: pan linear both;
  animation-timeline: view(block);
  animation-range: cover 0% cover 100%;
}
@keyframes pan {
  from { scale: 1.08; translate: 0 -1.5%; }
  to   { scale: 1;    translate: 0  1.5%; }
}

/* Gallery item progress from a horizontal scroller */
.gallery { overflow-x: auto; scroll-timeline: --gallery inline; }
.gallery .item {
  animation: settle linear both;
  animation-timeline: view(inline);
}

Minimal working example

A single figure that wipes into view as it crosses the scrollport, with a baseline that shows the image immediately when timelines are unavailable:

<figure class="reveal">
  <img src="/media/coast.avif" alt="Cliffs above a grey sea" width="1600" height="900">
  <figcaption>Approaching the headland.</figcaption>
</figure>
.reveal img {
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 16 / 9;   /* reserve the box so nothing shifts when it loads */
}

@supports (animation-timeline: view()) {
  .reveal img {
    clip-path: inset(0 0 100% 0);
    animation: reveal-up linear both;
    animation-timeline: view(block);
    animation-range: entry 15% entry 85%;
  }
  @keyframes reveal-up { to { clip-path: inset(0 0 0 0); } }
}

@media (prefers-reduced-motion: reduce) {
  .reveal img { animation: none; clip-path: none; }
}

Three things make this production-ready rather than a demo: the reserved aspect ratio, the feature query around the enhancement, and the reduced-motion reset that restores the un-clipped image rather than leaving it half-covered.

Clip reveal versus opacity fade Two treatments of the same image. An opacity fade changes the whole surface uniformly and reads as the image arriving. A clip-path wipe uncovers the image progressively from one edge and reads as the image being drawn, which suits editorial photography where the composition should be discovered rather than announced. Opacity fade β€” 40% through the whole image is half-present Clip-path wipe β€” 40% through shown not yet uncovered Which to choose Fade: cheapest, works on any shape, reads as "this arrived". Clip: directional, reads as "this is being revealed", and can follow the composition's own direction. Both animate on the compositor; neither forces layout.

animation-range and timeline scoping

Media effects are unusually sensitive to range choice, because the subject is large. A full-bleed image occupies most of the scrollport, so its cover range is dominated by the time it spends fully visible β€” and an animation spread across that whole range moves imperceptibly slowly for most of it and then finishes off-screen.

Two range shapes cover almost all media work. A reveal wants a short range early in the journey: entry 15% to entry 85% completes while the subject is arriving, so it has finished by the time the reader is looking at the composition. A pan wants the whole journey: cover 0% to cover 100% spreads a small movement across the entire time the image is on screen, which is what makes it feel ambient rather than deliberate.

For galleries, view(inline) gives each item its own progress and needs no per-item ranges at all. Reaching for nth-child delays in a gallery is the signal that a scroll timeline has been used where a view timeline was wanted.

Compositor-safe properties

The compositor-safe set is the same everywhere β€” transform, opacity, filter, and clip-path when its shape is interpolable β€” but the consequences of leaving it are larger for media. A large image re-rasterised every frame is expensive in a way that a re-rasterised text block is not, simply because there are more pixels.

Three property choices matter specifically here. Animate scale and translate rather than width, height, top or background-position; the last of these is the most common mistake in parallax-style image work and repaints the entire layer on every frame. Animate clip-path between two inset() shapes with the same structure, so the interpolation is a simple numeric one. And prefer filter: brightness() to animating a colour overlay’s background-color, since the filter stays on the compositor.

Where a media effect actually costs time A timeline of one item entering the viewport. Decoding a large image is the expensive step and it happens on the main thread. If it begins when the reveal begins, the first frames of the animation are dropped. Decoding ahead of the reveal β€” by reserving space and starting the fetch earlier β€” moves the cost out of the animation window. Decode inside the reveal fetch decode β€” main thread reveal animates the first frames of the reveal are dropped while decoding blocks Decode ahead of it fetch decode reveal animates β€” clean a gap between decode and reveal is what makes the animation smooth Reserve the box with aspect-ratio, and set fetchpriority or a generous rootMargin so decoding finishes early.

Common implementation patterns

Pattern 1 β€” the editorial wipe. A directional clip-path reveal on a full-width figure, ranged to finish as the image settles. Works best when the direction follows the composition β€” a horizon revealed left to right, a portrait revealed bottom to top.

Pattern 2 β€” the ambient pan. A slow scale from 1.08 to 1 across the whole cover range, with a small counter-translate so the crop drifts rather than simply zooming. Total movement should stay small; the effect is meant to be noticed only in aggregate.

Pattern 3 β€” the settling gallery. Items in a horizontal scroller each read view(inline) and scale from 0.92 to 1 as they reach the centre. This produces a focus effect with no JavaScript and no index arithmetic, and it degrades to a plain row of equal-sized items.

Pattern 4 β€” the pinned stack. Cards pinned with position: sticky while a view timeline scales and dims the ones behind. Visually striking, and the most expensive pattern here in layer terms, because every pinned card holds a composited layer for the duration.

Browser support and @supports guard

All the patterns in this topic sit behind the same guard as the rest of the site’s scroll-driven work.

.figure img { clip-path: none; }   /* baseline: fully visible */

@supports (animation-timeline: view()) {
  .figure img { /* the enhancement */ }
}

clip-path with inset() is supported everywhere relevant and needs no separate query. Animating between clip-path shapes of different types β€” an inset to a polygon β€” is not interpolable and falls back to a discrete swap in every engine, so keep both endpoints the same shape function.

Gotchas and failure modes

  1. Unreserved image boxes. An image without width, height or aspect-ratio has no box until it loads, so everything below shifts when it arrives β€” and any view timeline measuring it re-derives mid-scroll. Reserve the box; this is the single highest-value fix in this topic.

  2. Decoding inside the animation window. A large image that begins decoding as its reveal starts drops the first frames. Start the fetch earlier, keep the decoded size close to the displayed size, and prefer modern formats.

  3. Animating background-position for a pan. It repaints the layer every frame. Use a transform on an oversized image inside a clipping container instead.

  4. Full-bleed displacement. Movement that occupies the whole visual field is the strongest vection trigger there is. Keep media displacement well inside the vestibular-safe ranges β€” for full-width imagery, smaller than you would use for a card.

  5. Mandatory scroll-snap with a continuous indicator. Snapping moves the scroller in steps, so anything reading its progress steps too. Use proximity snapping when an indicator needs to glide.

  6. Layer count in stacked-card sections. Each pinned card is a composited layer held for the whole section. Ten cards on a phone is a meaningful amount of GPU memory before anything has animated.

Performance checklist

  • Reserve every media box with aspect-ratio or explicit dimensions.
  • Serve images at close to their displayed size; a 4000-pixel-wide source in a 800-pixel slot costs decode time and texture memory for nothing.
  • Animate only transform, opacity, filter and same-shape clip-path.
  • Count composited layers on the busiest media section at a mobile viewport.
  • Confirm zero paint entries during a scroll through the section.
  • Check the reduced-motion path restores un-clipped, un-scaled media rather than freezing it mid-effect.

What makes media different from the rest of this section

Everything in this topic could be described as β€œthe same techniques applied to images”, and that description would miss the three constraints that actually shape the work.

Size. A full-bleed photograph is a large raster. Its composited layer costs width times height times four bytes times the square of the device pixel ratio, which on a phone means a single hero image can reserve more GPU memory than an entire page of text and cards. Effects that promote several large images simultaneously β€” a stacked-card section, a gallery with per-item transforms β€” accumulate that cost quickly, and the symptom when they exceed what the device can hold is not slowness but blank flashes where the image should be.

Decode timing. Text is laid out and painted from data the browser already has. An image has to be fetched, decoded and uploaded to the GPU before it can be composited, and the decode step happens on the main thread. When decoding overlaps an animation’s opening frames, those frames are dropped β€” and because the animation is scroll-driven, the reader experiences it as the effect starting late rather than as a slow image.

Visual weight. A card that slides 24 pixels is a subtle transition. A full-width photograph that slides the same 24 pixels moves a large fraction of the visual field, which is a materially stronger vestibular trigger. Displacement caps that are comfortable for interface elements need to be reduced further for media, and the honest test is not whether it looks good but whether it still looks good on a phone held close to the face.

None of these are reasons to avoid scroll-driven media effects. They are the reasons the same effect needs different numbers here: smaller displacements, tighter ranges, fewer simultaneously promoted elements, and reserved boxes without exception.

Choosing between the patterns

The six guides under this topic solve overlapping problems, and picking between them is mostly a question of what the media is doing on the page.

When the image is the content β€” an editorial photograph, a product shot, a diagram the reader is expected to study β€” reveal it and then leave it alone. A short clip-path wipe or a fade during entry gives the arrival some presence without asking the reader to look at a moving target while they are trying to read it. Continuous movement on an image someone is examining is actively unhelpful.

When the image is the backdrop β€” a hero, a section break, a texture behind text β€” a slow ambient pan across the whole visible range is the appropriate treatment. The movement should be small enough that no single moment reads as motion; what the reader notices is that the section feels alive rather than that something moved.

When there are many images at once β€” a gallery, a grid, a carousel β€” per-item progress from a view timeline is the pattern, and the interesting decision is the axis. A horizontal row gives you a scroller whose progress is meaningful in its own right, which is what makes an indicator worth adding. A vertical grid usually does not; each item reveals on its own and there is nothing global to indicate.

When the section is a narrative β€” a sequence the reader is meant to move through step by step β€” pinned stacking or snap-coupled effects give each step its own moment. These are the most expensive patterns in the topic and the ones most likely to fight with a reader who simply wants to get past them, so they earn their place on a landing page and rarely on a documentation page.

Frequently Asked Questions

Should scroll-driven media effects run on mobile at all?

Yes, with smaller numbers. The compositor path is if anything more valuable on mobile, where the main thread is slower and a scroll-event-driven equivalent would visibly stutter. What changes is the budget: fewer simultaneously promoted layers, smaller displacements because the visual field is proportionally larger, and stricter attention to decoded image size because both memory and bandwidth are tighter.

The pattern to avoid on mobile is not any particular effect but the accumulation of several at once β€” a pinned stack inside a page that also has a parallax hero and a horizontal gallery will exhaust GPU memory on a mid-range device long before any individual effect is at fault.

Can these effects be applied to video?

To a video element’s position and scale, yes β€” it is an ordinary box and transforms apply normally. To the video’s playback position, no, or at least not well: scrubbing a video from scroll progress requires seeking on the main thread, and seeking is not frame-accurate or fast enough to track a scroll gesture. The scroll-scrubbed video effect that appears in showcase sites is almost always a pre-rendered image sequence rather than a video, which is a different technique with different costs.

How do these interact with lazy loading?

Reserve the box and the two cooperate well. loading="lazy" defers the fetch until the image is near the viewport, which is exactly when a view timeline is about to start measuring it β€” so the decode can land inside the animation window unless the loading threshold is generous. Where an effect must be smooth from its first frame, either load that image eagerly or reserve enough distance in the loading threshold that decoding finishes before the reveal begins.

Do clip-path reveals hurt accessibility?

Not inherently β€” the image remains in the accessibility tree and its alt text is unaffected by clipping. What does matter is the reduced-motion path: a reveal that is reset by setting animation: none while leaving the initial clip-path in place hides the image permanently for anyone with the preference set. Reset the clip as well, which is why the examples here do both.


Up: Scroll-Driven & View Transition Implementation Patterns