Horizontal Panoramic Pan Reveal
Axis conversion: the visitor scrolls vertically and the image pans horizontally, so a wide interior or landscape reads as a continuous camera move. Two implementations — an animated object-position (one property, no wrapper, recommended) and an oversized translateX inside a clipped frame when you also want parallax depth.
Published
The code
<section class="rv-11" tabindex="0" role="region" aria-label="Panoramic pan reveal demo">
<p class="rv-11__hint">scroll inside to pan the panorama <span aria-hidden="true">↓</span></p>
<figure class="rv-11__item">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=2000&q=72"
alt="Wide panorama of a snowy mountain ridge at night" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__item--transform">
<p class="rv-11__badge">variant b · oversized translateX (adds parallax)</p>
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=2000&q=72"
alt="Mountain range above low cloud, panoramic crop" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__uc">
<p class="rv-11__uclabel"><b>real-world use case</b> property listing · gallery hero</p>
<div class="shell">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?auto=format&fit=crop&w=1600&q=70" alt="Panoramic interior of a renovated apartment" width="1600" height="686" loading="lazy" decoding="async">
</div>
<div class="row">
<div class="col">
<p class="eyebrow">Just listed · Clifton, Bristol</p>
<h5>Two-bed garden flat, Georgian conversion</h5>
<div class="meta"><span>2 bed</span><span>1 bath</span><span>81 m²</span><span>EPC C</span></div>
</div>
<div class="cta"><span class="price">£425,000</span><span class="btn">Book a viewing</span></div>
</div>
</div>
<p class="why">Property galleries live or die on wide interiors. Panning the crop as the visitor scrolls shows the whole room without a carousel, without a drag interaction, and without stealing the scroll.</p>
</figure>
</section><section class="rv-11" tabindex="0" role="region" aria-label="Panoramic pan reveal demo">
<p class="rv-11__hint">scroll inside to pan the panorama <span aria-hidden="true">↓</span></p>
<figure class="rv-11__item">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=2000&q=72"
alt="Wide panorama of a snowy mountain ridge at night" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__item--transform">
<p class="rv-11__badge">variant b · oversized translateX (adds parallax)</p>
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=2000&q=72"
alt="Mountain range above low cloud, panoramic crop" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__uc">
<p class="rv-11__uclabel"><b>real-world use case</b> property listing · gallery hero</p>
<div class="shell">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?auto=format&fit=crop&w=1600&q=70" alt="Panoramic interior of a renovated apartment" width="1600" height="686" loading="lazy" decoding="async">
</div>
<div class="row">
<div class="col">
<p class="eyebrow">Just listed · Clifton, Bristol</p>
<h5>Two-bed garden flat, Georgian conversion</h5>
<div class="meta"><span>2 bed</span><span>1 bath</span><span>81 m²</span><span>EPC C</span></div>
</div>
<div class="cta"><span class="price">£425,000</span><span class="btn">Book a viewing</span></div>
</div>
</div>
<p class="why">Property galleries live or die on wide interiors. Panning the crop as the visitor scrolls shows the whole room without a carousel, without a drag interaction, and without stealing the scroll.</p>
</figure>
</section>.rv-11 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 22px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-11 * {
box-sizing: border-box;
}
.rv-11__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
/* cover ranges need a runway TALLER than the stage before and after each subject.
% margins are width-relative, so the min-height floors guarantee it in narrow embeds. */
.rv-11__item {
margin: 0 auto 20%;
padding-top: 20%;
width: 620px;
min-height: 710px;
}
.rv-11__item:last-child {
padding-bottom: 20%;
}
.rv-11__badge {
display: inline-block;
margin: 0 0 10px;
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent);
background: oklch(0.88 0.19 118/.1);
border: 1px solid oklch(0.88 0.19 118/.28);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16/10;
object-fit: cover;
border-radius: 18px;
}
/* clip, not hidden — hidden would become the view() timeline source */
.rv-11__frame {
overflow: clip;
border-radius: 18px;
}
.rv-11__frame img {
width: 132%;
border-radius: 0;
}
.rv-11 figcaption {
margin-top: 14px;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── COVER range: a pan needs the element's whole journey ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-11__frame img {
animation: rv11-pan-tf linear both;
animation-timeline: view();
animation-range: entry 40% exit 20%;
}
}
}
@keyframes rv11-pan-obj {
from {
object-position: 0% 50%;
}
to {
object-position: 100% 50%;
}
}
@keyframes rv11-pan-tf {
from {
transform: translateX(-24%);
}
to {
transform: translateX(0);
}
}
@supports not (animation-timeline: view()) {
.rv-11__frame img {
transform: translateX(calc(-24% * (1 - var(--p,0))));
}
}
@media (prefers-reduced-motion: reduce) {
.rv-11 img {
object-position: 50% 50%;
transform: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-11__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-11__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11__uc h4 {
margin: 0;
font: 600 clamp(19px,2.8vw,34px)/1.1 system-ui,sans-serif;
letter-spacing: -.028em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-11__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-11__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-11__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-11__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-11__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-11__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-11__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-11__uc .over {
position: relative;
border-radius: 20px;
overflow: clip;
background: oklch(0.13 0.008 260);
}
.rv-11__uc .col {
display: grid;
gap: 6px;
}
.rv-11__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-11__uc .shell img {
border-radius: 0;
}
.rv-11__uc .row {
display: flex;
gap: 18px;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
padding: 20px 22px;
}
.rv-11__uc .col {
gap: 7px;
}.rv-11 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 22px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-11 * {
box-sizing: border-box;
}
.rv-11__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
/* cover ranges need a runway TALLER than the stage before and after each subject.
% margins are width-relative, so the min-height floors guarantee it in narrow embeds. */
.rv-11__item {
margin: 0 auto 20%;
padding-top: 20%;
width: 620px;
min-height: 710px;
}
.rv-11__item:last-child {
padding-bottom: 20%;
}
.rv-11__badge {
display: inline-block;
margin: 0 0 10px;
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent);
background: oklch(0.88 0.19 118/.1);
border: 1px solid oklch(0.88 0.19 118/.28);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16/10;
object-fit: cover;
border-radius: 18px;
}
/* clip, not hidden — hidden would become the view() timeline source */
.rv-11__frame {
overflow: clip;
border-radius: 18px;
}
.rv-11__frame img {
width: 132%;
border-radius: 0;
}
.rv-11 figcaption {
margin-top: 14px;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── COVER range: a pan needs the element's whole journey ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-11__frame img {
animation: rv11-pan-tf linear both;
animation-timeline: view();
animation-range: entry 40% exit 20%;
}
}
}
@keyframes rv11-pan-obj {
from {
object-position: 0% 50%;
}
to {
object-position: 100% 50%;
}
}
@keyframes rv11-pan-tf {
from {
transform: translateX(-24%);
}
to {
transform: translateX(0);
}
}
@supports not (animation-timeline: view()) {
.rv-11__frame img {
transform: translateX(calc(-24% * (1 - var(--p,0))));
}
}
@media (prefers-reduced-motion: reduce) {
.rv-11 img {
object-position: 50% 50%;
transform: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-11__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-11__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11__uc h4 {
margin: 0;
font: 600 clamp(19px,2.8vw,34px)/1.1 system-ui,sans-serif;
letter-spacing: -.028em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-11__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-11__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-11__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-11__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-11__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-11__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-11__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-11__uc .over {
position: relative;
border-radius: 20px;
overflow: clip;
background: oklch(0.13 0.008 260);
}
.rv-11__uc .col {
display: grid;
gap: 6px;
}
.rv-11__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-11__uc .shell img {
border-radius: 0;
}
.rv-11__uc .row {
display: flex;
gap: 18px;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
padding: 20px 22px;
}
.rv-11__uc .col {
gap: 7px;
}/* OPTIONAL — progressive enhancement only. Writes a 0-1 journey progress so Firefox pans too. */
if (!CSS.supports('animation-timeline: view()')) {
const items = [...document.querySelectorAll('.rv-11__item')];
let queued = false;
const update = () => {
queued = false;
for (const el of items) {
const r = el.getBoundingClientRect();
const span = window.innerHeight + r.height; // the full cover journey
const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / span));
el.style.setProperty('--p', p.toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}/* OPTIONAL — progressive enhancement only. Writes a 0-1 journey progress so Firefox pans too. */
if (!CSS.supports('animation-timeline: view()')) {
const items = [...document.querySelectorAll('.rv-11__item')];
let queued = false;
const update = () => {
queued = false;
for (const el of items) {
const r = el.getBoundingClientRect();
const span = window.innerHeight + r.height; // the full cover journey
const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / span));
el.style.setProperty('--p', p.toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}Here's a working CSS Image Reveal on Scroll from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Horizontal Panoramic Pan Reveal
Source: https://codefronts.com/motion/css-image-reveal-scroll/horizontal-panoramic-pan-reveal/
Axis conversion: the visitor scrolls vertically and the image pans horizontally, so a wide interior or landscape reads as a continuous camera move. Two implementations — an animated object-position (one property, no wrapper, recommended) and an oversized translateX inside a clipped frame when you also want parallax depth.
## HTML
```html
<section class="rv-11" tabindex="0" role="region" aria-label="Panoramic pan reveal demo">
<p class="rv-11__hint">scroll inside to pan the panorama <span aria-hidden="true">↓</span></p>
<figure class="rv-11__item">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=2000&q=72"
alt="Wide panorama of a snowy mountain ridge at night" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__item--transform">
<p class="rv-11__badge">variant b · oversized translateX (adds parallax)</p>
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=2000&q=72"
alt="Mountain range above low cloud, panoramic crop" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__uc">
<p class="rv-11__uclabel"><b>real-world use case</b> property listing · gallery hero</p>
<div class="shell">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?auto=format&fit=crop&w=1600&q=70" alt="Panoramic interior of a renovated apartment" width="1600" height="686" loading="lazy" decoding="async">
</div>
<div class="row">
<div class="col">
<p class="eyebrow">Just listed · Clifton, Bristol</p>
<h5>Two-bed garden flat, Georgian conversion</h5>
<div class="meta"><span>2 bed</span><span>1 bath</span><span>81 m²</span><span>EPC C</span></div>
</div>
<div class="cta"><span class="price">£425,000</span><span class="btn">Book a viewing</span></div>
</div>
</div>
<p class="why">Property galleries live or die on wide interiors. Panning the crop as the visitor scrolls shows the whole room without a carousel, without a drag interaction, and without stealing the scroll.</p>
</figure>
</section>
```
## CSS
```css
.rv-11 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 22px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-11 * {
box-sizing: border-box;
}
.rv-11__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
/* cover ranges need a runway TALLER than the stage before and after each subject.
% margins are width-relative, so the min-height floors guarantee it in narrow embeds. */
.rv-11__item {
margin: 0 auto 20%;
padding-top: 20%;
width: 620px;
min-height: 710px;
}
.rv-11__item:last-child {
padding-bottom: 20%;
}
.rv-11__badge {
display: inline-block;
margin: 0 0 10px;
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent);
background: oklch(0.88 0.19 118/.1);
border: 1px solid oklch(0.88 0.19 118/.28);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16/10;
object-fit: cover;
border-radius: 18px;
}
/* clip, not hidden — hidden would become the view() timeline source */
.rv-11__frame {
overflow: clip;
border-radius: 18px;
}
.rv-11__frame img {
width: 132%;
border-radius: 0;
}
.rv-11 figcaption {
margin-top: 14px;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── COVER range: a pan needs the element's whole journey ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-11__frame img {
animation: rv11-pan-tf linear both;
animation-timeline: view();
animation-range: entry 40% exit 20%;
}
}
}
@keyframes rv11-pan-obj {
from {
object-position: 0% 50%;
}
to {
object-position: 100% 50%;
}
}
@keyframes rv11-pan-tf {
from {
transform: translateX(-24%);
}
to {
transform: translateX(0);
}
}
@supports not (animation-timeline: view()) {
.rv-11__frame img {
transform: translateX(calc(-24% * (1 - var(--p,0))));
}
}
@media (prefers-reduced-motion: reduce) {
.rv-11 img {
object-position: 50% 50%;
transform: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-11__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-11__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11__uc h4 {
margin: 0;
font: 600 clamp(19px,2.8vw,34px)/1.1 system-ui,sans-serif;
letter-spacing: -.028em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-11__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-11__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-11__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-11__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-11__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-11__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-11__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-11__uc .over {
position: relative;
border-radius: 20px;
overflow: clip;
background: oklch(0.13 0.008 260);
}
.rv-11__uc .col {
display: grid;
gap: 6px;
}
.rv-11__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-11__uc .shell img {
border-radius: 0;
}
.rv-11__uc .row {
display: flex;
gap: 18px;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
padding: 20px 22px;
}
.rv-11__uc .col {
gap: 7px;
}
```
## JavaScript
```js
/* OPTIONAL — progressive enhancement only. Writes a 0-1 journey progress so Firefox pans too. */
if (!CSS.supports('animation-timeline: view()')) {
const items = [...document.querySelectorAll('.rv-11__item')];
let queued = false;
const update = () => {
queued = false;
for (const el of items) {
const r = el.getBoundingClientRect();
const span = window.innerHeight + r.height; // the full cover journey
const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / span));
el.style.setProperty('--p', p.toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}
```Here's a working CSS Image Reveal on Scroll from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Horizontal Panoramic Pan Reveal
Source: https://codefronts.com/motion/css-image-reveal-scroll/horizontal-panoramic-pan-reveal/
Axis conversion: the visitor scrolls vertically and the image pans horizontally, so a wide interior or landscape reads as a continuous camera move. Two implementations — an animated object-position (one property, no wrapper, recommended) and an oversized translateX inside a clipped frame when you also want parallax depth.
## HTML
```html
<section class="rv-11" tabindex="0" role="region" aria-label="Panoramic pan reveal demo">
<p class="rv-11__hint">scroll inside to pan the panorama <span aria-hidden="true">↓</span></p>
<figure class="rv-11__item">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=2000&q=72"
alt="Wide panorama of a snowy mountain ridge at night" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__item--transform">
<p class="rv-11__badge">variant b · oversized translateX (adds parallax)</p>
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=2000&q=72"
alt="Mountain range above low cloud, panoramic crop" width="2000" height="857"
loading="lazy" decoding="async">
</div>
<figcaption>transform: translateX(-24%) → 0 · img is 132% wide</figcaption>
</figure>
<figure class="rv-11__item rv-11__uc">
<p class="rv-11__uclabel"><b>real-world use case</b> property listing · gallery hero</p>
<div class="shell">
<div class="rv-11__frame">
<img src="https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?auto=format&fit=crop&w=1600&q=70" alt="Panoramic interior of a renovated apartment" width="1600" height="686" loading="lazy" decoding="async">
</div>
<div class="row">
<div class="col">
<p class="eyebrow">Just listed · Clifton, Bristol</p>
<h5>Two-bed garden flat, Georgian conversion</h5>
<div class="meta"><span>2 bed</span><span>1 bath</span><span>81 m²</span><span>EPC C</span></div>
</div>
<div class="cta"><span class="price">£425,000</span><span class="btn">Book a viewing</span></div>
</div>
</div>
<p class="why">Property galleries live or die on wide interiors. Panning the crop as the visitor scrolls shows the whole room without a carousel, without a drag interaction, and without stealing the scroll.</p>
</figure>
</section>
```
## CSS
```css
.rv-11 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 22px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-11 * {
box-sizing: border-box;
}
.rv-11__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
/* cover ranges need a runway TALLER than the stage before and after each subject.
% margins are width-relative, so the min-height floors guarantee it in narrow embeds. */
.rv-11__item {
margin: 0 auto 20%;
padding-top: 20%;
width: 620px;
min-height: 710px;
}
.rv-11__item:last-child {
padding-bottom: 20%;
}
.rv-11__badge {
display: inline-block;
margin: 0 0 10px;
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent);
background: oklch(0.88 0.19 118/.1);
border: 1px solid oklch(0.88 0.19 118/.28);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16/10;
object-fit: cover;
border-radius: 18px;
}
/* clip, not hidden — hidden would become the view() timeline source */
.rv-11__frame {
overflow: clip;
border-radius: 18px;
}
.rv-11__frame img {
width: 132%;
border-radius: 0;
}
.rv-11 figcaption {
margin-top: 14px;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── COVER range: a pan needs the element's whole journey ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-11__frame img {
animation: rv11-pan-tf linear both;
animation-timeline: view();
animation-range: entry 40% exit 20%;
}
}
}
@keyframes rv11-pan-obj {
from {
object-position: 0% 50%;
}
to {
object-position: 100% 50%;
}
}
@keyframes rv11-pan-tf {
from {
transform: translateX(-24%);
}
to {
transform: translateX(0);
}
}
@supports not (animation-timeline: view()) {
.rv-11__frame img {
transform: translateX(calc(-24% * (1 - var(--p,0))));
}
}
@media (prefers-reduced-motion: reduce) {
.rv-11 img {
object-position: 50% 50%;
transform: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-11__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-11__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-11__uc h4 {
margin: 0;
font: 600 clamp(19px,2.8vw,34px)/1.1 system-ui,sans-serif;
letter-spacing: -.028em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-11__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-11__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-11__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-11__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-11__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-11__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-11__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-11__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-11__uc .over {
position: relative;
border-radius: 20px;
overflow: clip;
background: oklch(0.13 0.008 260);
}
.rv-11__uc .col {
display: grid;
gap: 6px;
}
.rv-11__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-11__uc .shell img {
border-radius: 0;
}
.rv-11__uc .row {
display: flex;
gap: 18px;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
padding: 20px 22px;
}
.rv-11__uc .col {
gap: 7px;
}
```
## JavaScript
```js
/* OPTIONAL — progressive enhancement only. Writes a 0-1 journey progress so Firefox pans too. */
if (!CSS.supports('animation-timeline: view()')) {
const items = [...document.querySelectorAll('.rv-11__item')];
let queued = false;
const update = () => {
queued = false;
for (const el of items) {
const r = el.getBoundingClientRect();
const span = window.innerHeight + r.height; // the full cover journey
const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / span));
el.style.setProperty('--p', p.toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}
```How this works
The effect converts one axis into another: vertical scroll progress drives horizontal movement. In the transform implementation the image is deliberately wider than its frame (width: 132%) inside an overflow: clip wrapper, and translateX(-24%) to translateX(0) slides the surplus across, so the frame behaves like a window onto a panorama.
The recommended implementation does the same job with a single property and no oversized element: object-position: 0% 50% to 100% 50% moves the crop window inside the image box. Since object-position is animatable, the element's size never changes, no wrapper is needed, and the pattern is immune to the horizontal-scroll bug that oversized transforms invite. Reach for the transform version only when you also want the image to parallax against a background.
Both use the cover range rather than entry, over cover 25% to 92%. A pan needs the element's whole journey through the viewport to feel like a continuous camera move; on an entry range the pan would finish the moment the image became fully visible — which is when the user has only just started looking at it.
Make it yours
- Prefer the
object-positionversion by default: one animatable property, no oversized box, no horizontal-scroll risk. - Match pan distance to the overflow — a 132%-wide image should travel about 24%, not 50%, or it hits the edge early.
- Add
scale(1.04)to the transform version for a subtle dolly alongside the pan. - Pan vertically instead (
object-position: 50% 0%to50% 100%) for tall architectural shots. - Widen the range (
cover 0% cover 100%) for a slower, more cinematic sweep.
Gotchas — read before shipping
- Use the
coverrange, notentry— an entry range finishes the pan before the user has really seen the image. - Serve a genuinely wide source (21:9 or wider). Panning a 16:9 crop just slides the same content around.
- The transform version needs
overflow: clipon the frame or the oversized image causes horizontal page scroll on mobile. - Do not animate
background-positionon a CSS background image instead — it is not composited and janks on mid-range Android. object-positiononly affects replaced elements withobject-fitset; withoutobject-fit: covernothing moves.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 115+ | 26+ | flag | 115+ |
object-fit/object-position are universal since 2016 and object-position is animatable everywhere. Only animation-timeline: view() is new; the js fallback drives object-position from a scroll-progress custom property.