Rejouice — loading0

Frame parallax media

An image that drifts slowly inside a fixed frame as the page scrolls, so the frame and its contents move at different rates.

Grade: ARuntimes: css · gsapStatus: code-only

Documented, no live demo yet — the code below is complete.

Intent

Depth without a 3D scene. Because the frame is stationary relative to the page and only the image inside it moves, the effect stays subtle and never causes the whitespace gaps that whole-element parallax produces at the edges of the viewport.

Implementation

The observer gates the work: off-screen frames cost nothing, and only intersecting frames get a transform written.

Dependencies: none

html-css-js
<figure class="pframe" data-parallax>
  <img src="/media/studio.jpg" alt="" width="1800" height="2400" loading="lazy" decoding="async" />
</figure>

<style>
.pframe {
  overflow: hidden;
  aspect-ratio: 4 / 5;
  border-radius: var(--frame-radius, 0);
  margin: 0;
}

.pframe img {
  width: 100%;
  height: 125%;
  object-fit: cover;
  transform: translate3d(0, var(--y, 0px), 0);
}

@media (prefers-reduced-motion: reduce) {
  .pframe img { transform: translate3d(0, -12.5%, 0); }
}
</style>

<script type="module">
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
  const DEPTH = 0.18;
  const live = new Set();

  const io = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) live.add(entry.target);
        else live.delete(entry.target);
      }
    },
    { rootMargin: "10% 0px" },
  );

  for (const frame of document.querySelectorAll("[data-parallax]")) io.observe(frame);

  const frame = () => {
    for (const el of live) {
      const rect = el.getBoundingClientRect();
      // -1 when the frame is below the fold, +1 when it is above.
      const centred = (rect.top + rect.height / 2 - innerHeight / 2) / innerHeight;
      const travel = rect.height * 0.25;
      const img = el.firstElementChild;
      img.style.setProperty("--y", -travel / 2 + centred * travel * DEPTH * 4 + "px");
    }
    requestAnimationFrame(frame);
  };

  requestAnimationFrame(frame);
}
</script>

Use it when

  • Editorial imagery inside a text-led page.
  • Case-study hero images and full-bleed breaks.

Avoid it when

  • On more than a handful of images per page — each one is a compositing layer.
  • With text baked into the image, which will drift out of alignment with the frame.

Accessibility

  • Under reduced motion the image sits centred and static; the layout is identical, only the drift is gone.
  • Parallax is decorative — it must never be the only cue that a section changed.

Performance

  • One shared observer to decide which frames are live; only translate frames currently intersecting.
  • `overscale` must be at least `1 + 2 * depth` or the image edge will be exposed at the extremes.
  • Do not use `background-attachment: fixed` — it is the classic implementation and it repaints the whole layer on mobile.

Knobs

depth
Fraction of scroll distance the image travels. Default: 0.18
overscale
Extra image height so no edge is exposed. Default: 1.25

Composition

Pairs with

Conflicts with

None.

Instruction for Claude Code

Add frame parallax to editorial images: an overflow-hidden figure with a fixed aspect-ratio containing an image at 125% height and object-fit cover, translated via a --y custom property on translate3d. Use an IntersectionObserver with 10% rootMargin to track which frames are on screen and only write transforms for those, inside a single rAF loop. Derive the offset from the frame's centre relative to the viewport centre with a 0.18 depth factor. Under prefers-reduced-motion, centre the image statically at -12.5% and run no loop. Do not use background-attachment: fixed.