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