Open demo

CSS code

HTML
<div><span class="lbl">CSS 3D parallax in a scroll container</span>
<div class="plx plx-scene" tabindex="0" role="region" aria-label="Fjellstua mountain hut, scrollable parallax scene">
  <div class="plx-group">
    <div class="plx-layer plx-sky" aria-hidden="true"></div>
    <div class="plx-layer plx-sun" aria-hidden="true"></div>
    <div class="plx-layer plx-far" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#8d6f9f" d="M0 210 120 120l90 50 120-110 120 100 110-70 140 90 120-100 140 90 120-60 120 80v110H0Z"/></svg></div>
    <div class="plx-layer plx-mid" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#4b3f72" d="M0 180 160 70l110 90 150-130 130 120 120-70 170 110 140-90 220 120v100H0Z"/></svg></div>
    <div class="plx-layer plx-intro"><p>Fjellstua · 1,240 m</p><h3>A hut above the tree line</h3></div>
    <div class="plx-layer plx-front" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#1d2440" d="M0 150q150-70 300-20t300 10 300-30 300 10v180H0Z"/></svg></div>
  </div>
  <div class="plx-panel">
    <h3>Open from June to September</h3>
    <p>Fjellstua is a staffed mountain hut on the Rondane ridge, a four-hour walk from the nearest road. Dinner is served at seven, breakfast at six for anyone heading to the summit for sunrise.</p>
    <ul>
      <li><strong>38 beds</strong>In rooms of two to eight</li>
      <li><strong>4 h</strong>Walk in from Spranget</li>
      <li><strong>NOK 520</strong>Bed and breakfast</li>
    </ul>
  </div>
</div></div>

<div><span class="lbl">Scroll-timeline hero: slow photo, faster card</span>
<section class="plx plx-hero" aria-labelledby="plx-hero-title">
  <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&q=75" alt="Snow-dusted peaks rising above a sea of cloud at dusk" width="1600" height="1067" loading="lazy">
  <span class="plx-badge" style="--plx-speed:1.6">1,240 m</span>
  <span class="plx-badge" style="--plx-speed:2.2">Sunrise 04:12</span>
  <span class="plx-badge" style="--plx-speed:1.2">38 beds</span>
  <div class="plx-hero-card" style="--plx-speed:.8">
    <p>Summer 2027</p>
    <h2 id="plx-hero-title">Sleep above the clouds on the Rondane ridge</h2>
    <p>Bookings for June to September open on 1 February. Most weekends sell out within a week.</p>
  </div>
</section></div>

<div><span class="lbl">Image cards with the photo drifting in its frame</span>
<ul class="plx plx-cards" aria-label="Day walks from the hut">
  <li class="plx-card"><div class="plx-frame"><img src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=700&q=75" alt="Green valley with a river below snow-capped mountains" width="700" height="875" loading="lazy"></div><div><h3>Valley ridge loop</h3><p>14 km · 6 hours · moderate</p></div></li>
  <li class="plx-card"><div class="plx-frame"><img src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?auto=format&fit=crop&w=700&q=75" alt="Jagged peaks reflected in a still glacial lake at sunrise" width="700" height="875" loading="lazy"></div><div><h3>Mirror lake</h3><p>9 km · 3 hours · easy</p></div></li>
  <li class="plx-card"><div class="plx-frame"><img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=700&q=75" alt="A small boat crossing a turquoise lake between steep cliffs" width="700" height="875" loading="lazy"></div><div><h3>Boat to the north shore</h3><p>6 km · 2 hours · easy</p></div></li>
</ul></div>
CSS
/* Parallax layers. (1) A CSS-only 3D parallax: the scroll container has perspective, and each layer sits further
   back with translateZ() and is scaled up to keep its size, so deeper layers move more slowly. (2) and (3) use
   scroll-driven animations (animation-timeline: view()) inside @supports; other browsers show the same layout
   standing still. Under prefers-reduced-motion every layer is flat and static. */
.plx{--plx-ink:#10231e;--plx-muted:#4d5f59;--plx-accent:#b45309;--plx-radius:24px;color:var(--plx-ink)}
.plx :where(h2,h3,p,ul,figure){margin:0}
.plx img{display:block;max-width:100%}

/* 1. CSS 3D parallax inside its own scroll container */
.plx-scene{position:relative;height:520px;overflow-x:hidden;overflow-y:auto;perspective:300px;border-radius:var(--plx-radius);background:#1d2440;overscroll-behavior:contain}
.plx-scene:focus-visible{outline:3px solid var(--plx-accent);outline-offset:3px}
.plx-group{position:relative;height:520px;transform-style:preserve-3d}
.plx-layer{position:absolute;inset:0}
.plx-sky{background:linear-gradient(#1f2b4a 0%,#4a4378 38%,#9a6b8e 62%,#f0a27a 84%,#f8cf98 100%);transform:translateZ(-600px) scale(3)}
.plx-sun{transform:translateZ(-450px) scale(2.5)}
.plx-sun::after{content:"";position:absolute;left:58%;top:52%;width:90px;height:90px;border-radius:50%;background:radial-gradient(#fff4dc 30%,#ffd59a 60%,rgba(255,213,154,0) 72%)}
.plx-far{transform:translateZ(-300px) scale(2)}
.plx-mid{transform:translateZ(-150px) scale(1.5)}
.plx-layer svg{position:absolute;left:0;bottom:0;width:100%;height:62%}
.plx-mid svg{height:48%}
.plx-front svg{height:30%}
.plx-intro{display:flex;flex-direction:column;justify-content:flex-start;padding:clamp(28px,6vw,64px);color:#fff;transform:translateZ(-150px) scale(1.5)}
.plx-intro p{font-size:.8rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:#ffe2bf}
.plx-intro h3{margin-top:10px;max-width:14ch;font-size:clamp(2rem,1.3rem + 3vw,3.4rem);line-height:1.02;letter-spacing:-.03em}
/* translateZ(0) puts the panel in the 3D scene at depth 0, so it paints over the deeper layers */
.plx-panel{position:relative;z-index:1;transform:translateZ(0);padding:clamp(28px,5vw,48px);background:#1d2440;color:#e8ebf7}
.plx-panel h3{font-size:1.3rem;color:#fff}
.plx-panel p{margin-top:10px;max-width:60ch;color:#c9cfe6}
.plx-panel ul{list-style:none;padding:0;margin-top:22px;display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.plx-panel li{padding:14px 16px;border-radius:14px;background:#2a3358;font-size:.9rem;color:#c9cfe6}
.plx-panel li strong{display:block;font-size:1.25rem;color:#ffd7a8}

/* 2. scroll-timeline hero: slow photo, faster card and badges */
.plx-hero{position:relative;height:clamp(440px,72vh,620px);overflow:hidden;border-radius:var(--plx-radius);background:#23313a;isolation:isolate}
.plx-hero>img{position:absolute;left:0;top:-12%;width:100%;height:124%;object-fit:cover;z-index:-1}
.plx-hero-card{position:absolute;left:clamp(16px,5vw,56px);bottom:clamp(16px,5vw,56px);max-width:440px;padding:clamp(20px,3vw,30px);
  border-radius:20px;background:rgba(10,20,26,.82);color:#f4f7f6;backdrop-filter:blur(6px)}
.plx-hero-card p:first-child{font-size:.78rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#ffd7a8}
.plx-hero-card h2{margin-top:8px;font-size:clamp(1.6rem,1.2rem + 1.8vw,2.4rem);line-height:1.08;letter-spacing:-.02em}
.plx-hero-card p+p{margin-top:10px;color:#d3dcda}
.plx-badge{position:absolute;padding:10px 14px;border-radius:999px;background:#fff;color:var(--plx-ink);font-size:.86rem;font-weight:700;box-shadow:0 14px 30px -14px rgba(0,0,0,.6)}
.plx-badge:nth-of-type(1){top:12%;right:8%}
.plx-badge:nth-of-type(2){top:34%;right:22%}
.plx-badge:nth-of-type(3){top:20%;left:6%}
@media (max-width:640px){.plx-badge:nth-of-type(2){display:none}.plx-badge:nth-of-type(1){right:5%}}

/* 3. image cards with the photo drifting inside its frame */
.plx-cards{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}
.plx-card{overflow:hidden;border-radius:20px;background:#fff;box-shadow:0 20px 40px -32px rgba(16,35,30,.6)}
.plx-frame{position:relative;aspect-ratio:4/5;overflow:hidden}
.plx-frame img{position:absolute;left:0;top:-10%;width:100%;height:120%;object-fit:cover}
.plx-card div{padding:18px 20px 22px}
.plx-card h3{font-size:1.08rem}
.plx-card p{margin-top:4px;font-size:.9rem;color:var(--plx-muted)}

/* scroll-driven motion: progressive enhancement */
@keyframes plx-photo{from{transform:translateY(-9%)}to{transform:translateY(9%)}}
@keyframes plx-rise{from{transform:translateY(calc(var(--plx-speed,1) * 70px))}to{transform:translateY(calc(var(--plx-speed,1) * -70px))}}
@supports (animation-timeline:view()){
  @media (prefers-reduced-motion:no-preference){
    .plx-hero{view-timeline:--plx-hero block}
    .plx-frame{view-timeline:--plx-frame block}
    .plx-hero>img{animation:plx-photo linear both;animation-timeline:--plx-hero}
    .plx-frame img{animation:plx-photo linear both;animation-timeline:--plx-frame}
    .plx-hero-card,.plx-badge{animation:plx-rise linear both;animation-timeline:--plx-hero}
  }
}
@media (prefers-reduced-motion:reduce){
  .plx-scene{perspective:none}
  .plx-group{transform-style:flat}
  .plx-layer,.plx-intro{transform:none}
}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">CSS 3D parallax in a scroll container</span>
<div class="plx plx-scene rounded-4" tabindex="0" role="region" aria-label="Fjellstua mountain hut, scrollable parallax scene">
  <div class="plx-group">
    <div class="plx-layer plx-sky" aria-hidden="true"></div>
    <div class="plx-layer plx-sun" aria-hidden="true"></div>
    <div class="plx-layer plx-far" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#8d6f9f" d="M0 210 120 120l90 50 120-110 120 100 110-70 140 90 120-100 140 90 120-60 120 80v110H0Z"/></svg></div>
    <div class="plx-layer plx-mid" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#4b3f72" d="M0 180 160 70l110 90 150-130 130 120 120-70 170 110 140-90 220 120v100H0Z"/></svg></div>
    <div class="plx-layer plx-intro"><p>Fjellstua · 1,240 m</p><h3>A hut above the tree line</h3></div>
    <div class="plx-layer plx-front" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#1d2440" d="M0 150q150-70 300-20t300 10 300-30 300 10v180H0Z"/></svg></div>
  </div>
  <div class="plx-panel">
    <h3>Open from June to September</h3>
    <p>Fjellstua is a staffed mountain hut on the Rondane ridge, a four-hour walk from the nearest road. Dinner is served at seven, breakfast at six for anyone heading to the summit for sunrise.</p>
    <ul class="row row-cols-1 row-cols-sm-3 g-2 list-unstyled mt-3 mb-0">
      <li class="col"><div class="plx-stat h-100"><strong>38 beds</strong>In rooms of two to eight</div></li>
      <li class="col"><div class="plx-stat h-100"><strong>4 h</strong>Walk in from Spranget</div></li>
      <li class="col"><div class="plx-stat h-100"><strong>NOK 520</strong>Bed and breakfast</div></li>
    </ul>
  </div>
</div></div>

<div><span class="lbl">Scroll-timeline hero: slow photo, faster card</span>
<section class="plx plx-hero rounded-4" aria-labelledby="plx-hero-title">
  <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&q=75" alt="Snow-dusted peaks rising above a sea of cloud at dusk" width="1600" height="1067" loading="lazy">
  <span class="plx-badge badge rounded-pill text-bg-light position-absolute" style="--plx-speed:1.6">1,240 m</span>
  <span class="plx-badge badge rounded-pill text-bg-light position-absolute" style="--plx-speed:2.2">Sunrise 04:12</span>
  <span class="plx-badge badge rounded-pill text-bg-light position-absolute" style="--plx-speed:1.2">38 beds</span>
  <div class="plx-hero-card position-absolute" style="--plx-speed:.8">
    <p>Summer 2027</p>
    <h2 id="plx-hero-title">Sleep above the clouds on the Rondane ridge</h2>
    <p>Bookings for June to September open on 1 February. Most weekends sell out within a week.</p>
  </div>
</section></div>

<div><span class="lbl">Image cards with the photo drifting in its frame</span>
<ul class="plx row row-cols-1 row-cols-md-3 g-4 list-unstyled mb-0" aria-label="Day walks from the hut">
  <li class="col"><div class="plx-card card h-100 overflow-hidden"><div class="plx-frame"><img src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=700&q=75" alt="Green valley with a river below snow-capped mountains" width="700" height="875" loading="lazy"></div><div class="card-body"><h3 class="mb-1">Valley ridge loop</h3><p class="mb-0">14 km · 6 hours · moderate</p></div></div></li>
  <li class="col"><div class="plx-card card h-100 overflow-hidden"><div class="plx-frame"><img src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?auto=format&fit=crop&w=700&q=75" alt="Jagged peaks reflected in a still glacial lake at sunrise" width="700" height="875" loading="lazy"></div><div class="card-body"><h3 class="mb-1">Mirror lake</h3><p class="mb-0">9 km · 3 hours · easy</p></div></div></li>
  <li class="col"><div class="plx-card card h-100 overflow-hidden"><div class="plx-frame"><img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=700&q=75" alt="A small boat crossing a turquoise lake between steep cliffs" width="700" height="875" loading="lazy"></div><div class="card-body"><h3 class="mb-1">Boat to the north shore</h3><p class="mb-0">6 km · 2 hours · easy</p></div></div></li>
</ul></div>
CSS
/* Parallax layers in Bootstrap 5: .rounded-4, .card, .badge, the grid and position utilities for layout; the
   parallax is the shared CSS below. (1) A CSS-only 3D parallax: the scroll container has perspective, and each layer sits further
   back with translateZ() and is scaled up to keep its size, so deeper layers move more slowly. (2) and (3) use
   scroll-driven animations (animation-timeline: view()) inside @supports; other browsers show the same layout
   standing still. Under prefers-reduced-motion every layer is flat and static. */
.plx{--plx-ink:#10231e;--plx-muted:#4d5f59;--plx-accent:#b45309;--plx-radius:24px;color:var(--plx-ink)}
.plx :where(h2,h3,p,ul,figure){margin:0}
.plx img{display:block;max-width:100%}

/* 1. CSS 3D parallax inside its own scroll container */
.plx-scene{position:relative;height:520px;overflow-x:hidden;overflow-y:auto;perspective:300px;border-radius:var(--plx-radius);background:#1d2440;overscroll-behavior:contain}
.plx-scene:focus-visible{outline:3px solid var(--plx-accent);outline-offset:3px}
.plx-group{position:relative;height:520px;transform-style:preserve-3d}
.plx-layer{position:absolute;inset:0}
.plx-sky{background:linear-gradient(#1f2b4a 0%,#4a4378 38%,#9a6b8e 62%,#f0a27a 84%,#f8cf98 100%);transform:translateZ(-600px) scale(3)}
.plx-sun{transform:translateZ(-450px) scale(2.5)}
.plx-sun::after{content:"";position:absolute;left:58%;top:52%;width:90px;height:90px;border-radius:50%;background:radial-gradient(#fff4dc 30%,#ffd59a 60%,rgba(255,213,154,0) 72%)}
.plx-far{transform:translateZ(-300px) scale(2)}
.plx-mid{transform:translateZ(-150px) scale(1.5)}
.plx-layer svg{position:absolute;left:0;bottom:0;width:100%;height:62%}
.plx-mid svg{height:48%}
.plx-front svg{height:30%}
.plx-intro{display:flex;flex-direction:column;justify-content:flex-start;padding:clamp(28px,6vw,64px);color:#fff;transform:translateZ(-150px) scale(1.5)}
.plx-intro p{font-size:.8rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:#ffe2bf}
.plx-intro h3{margin-top:10px;max-width:14ch;font-weight:700;font-size:clamp(2rem,1.3rem + 3vw,3.4rem);line-height:1.02;letter-spacing:-.03em}
/* translateZ(0) puts the panel in the 3D scene at depth 0, so it paints over the deeper layers */
.plx-panel{position:relative;z-index:1;transform:translateZ(0);padding:clamp(28px,5vw,48px);background:#1d2440;color:#e8ebf7}
.plx-panel h3{font-size:1.3rem;font-weight:700;color:#fff}
.plx-panel p{margin-top:10px;max-width:60ch;color:#c9cfe6}
.plx-stat{padding:14px 16px;border-radius:14px;background:#2a3358;font-size:.9rem;color:#c9cfe6}
.plx-stat strong{display:block;font-size:1.25rem;color:#ffd7a8}

/* 2. scroll-timeline hero: slow photo, faster card and badges */
.plx-hero{position:relative;height:clamp(440px,72vh,620px);overflow:hidden;border-radius:var(--plx-radius);background:#23313a;isolation:isolate}
.plx-hero>img{position:absolute;left:0;top:-12%;width:100%;height:124%;object-fit:cover;z-index:-1}
.plx-hero-card{position:absolute;left:clamp(16px,5vw,56px);bottom:clamp(16px,5vw,56px);max-width:440px;padding:clamp(20px,3vw,30px);
  border-radius:20px;background:rgba(10,20,26,.82);color:#f4f7f6;backdrop-filter:blur(6px)}
.plx-hero-card p:first-child{font-size:.78rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#ffd7a8}
.plx-hero-card h2{margin-top:8px;font-weight:700;font-size:clamp(1.6rem,1.2rem + 1.8vw,2.4rem);line-height:1.08;letter-spacing:-.02em}
.plx-hero-card p+p{margin-top:10px;color:#d3dcda}
.plx-badge{--bs-badge-padding-x:14px;--bs-badge-padding-y:10px;--bs-badge-font-size:.86rem;color:var(--plx-ink)!important;box-shadow:0 14px 30px -14px rgba(0,0,0,.6)}
.plx-badge:nth-of-type(1){top:12%;right:8%}
.plx-badge:nth-of-type(2){top:34%;right:22%}
.plx-badge:nth-of-type(3){top:20%;left:6%}
@media (max-width:640px){.plx-badge:nth-of-type(2){display:none}.plx-badge:nth-of-type(1){right:5%}}

/* 3. image cards with the photo drifting inside its frame */
.plx-card{--bs-card-border-width:0;--bs-card-border-radius:20px;--bs-card-spacer-x:20px;--bs-card-spacer-y:18px;box-shadow:0 20px 40px -32px rgba(16,35,30,.6)}
.plx-frame{position:relative;aspect-ratio:4/5;overflow:hidden}
.plx-frame img{position:absolute;left:0;top:-10%;width:100%;height:120%;object-fit:cover}
.plx-card h3{font-size:1.08rem;font-weight:700}
.plx-card p{font-size:.9rem;color:var(--plx-muted)}

/* scroll-driven motion: progressive enhancement */
@keyframes plx-photo{from{transform:translateY(-9%)}to{transform:translateY(9%)}}
@keyframes plx-rise{from{transform:translateY(calc(var(--plx-speed,1) * 70px))}to{transform:translateY(calc(var(--plx-speed,1) * -70px))}}
@supports (animation-timeline:view()){
  @media (prefers-reduced-motion:no-preference){
    .plx-hero{view-timeline:--plx-hero block}
    .plx-frame{view-timeline:--plx-frame block}
    .plx-hero>img{animation:plx-photo linear both;animation-timeline:--plx-hero}
    .plx-frame img{animation:plx-photo linear both;animation-timeline:--plx-frame}
    .plx-hero-card,.plx-badge{animation:plx-rise linear both;animation-timeline:--plx-hero}
  }
}
@media (prefers-reduced-motion:reduce){
  .plx-scene{perspective:none}
  .plx-group{transform-style:flat}
  .plx-layer,.plx-intro{transform:none}
}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4d5f59] uppercase">CSS 3D parallax in a scroll container</span>
<div class="relative h-[520px] overflow-x-hidden overflow-y-auto overscroll-contain rounded-3xl bg-[#1d2440] perspective-[300px] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#b45309] motion-reduce:perspective-none" tabindex="0" role="region" aria-label="Fjellstua mountain hut, scrollable parallax scene">
  <div class="relative h-[520px] transform-3d motion-reduce:transform-flat">
    <div class="absolute inset-0 bg-[linear-gradient(#1f2b4a_0%,#4a4378_38%,#9a6b8e_62%,#f0a27a_84%,#f8cf98_100%)] [transform:translateZ(-600px)_scale(3)] motion-reduce:[transform:none]" aria-hidden="true"></div>
    <div class="absolute inset-0 [transform:translateZ(-450px)_scale(2.5)] motion-reduce:[transform:none]" aria-hidden="true"><i class="absolute top-[52%] left-[58%] size-[90px] rounded-full bg-[radial-gradient(#fff4dc_30%,#ffd59a_60%,rgba(255,213,154,0)_72%)]"></i></div>
    <div class="absolute inset-0 [transform:translateZ(-300px)_scale(2)] motion-reduce:[transform:none]" aria-hidden="true"><svg class="absolute bottom-0 left-0 h-[62%] w-full" viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#8d6f9f" d="M0 210 120 120l90 50 120-110 120 100 110-70 140 90 120-100 140 90 120-60 120 80v110H0Z"/></svg></div>
    <div class="absolute inset-0 [transform:translateZ(-150px)_scale(1.5)] motion-reduce:[transform:none]" aria-hidden="true"><svg class="absolute bottom-0 left-0 h-[48%] w-full" viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#4b3f72" d="M0 180 160 70l110 90 150-130 130 120 120-70 170 110 140-90 220 120v100H0Z"/></svg></div>
    <div class="absolute inset-0 flex flex-col p-[clamp(28px,6vw,64px)] text-white [transform:translateZ(-150px)_scale(1.5)] motion-reduce:[transform:none]"><p class="text-[.8rem] font-bold tracking-[.16em] text-[#ffe2bf] uppercase">Fjellstua · 1,240 m</p><h3 class="mt-2.5 max-w-[14ch] text-[clamp(2rem,1.3rem+3vw,3.4rem)] leading-[1.02] font-bold tracking-[-.03em]">A hut above the tree line</h3></div>
    <div class="absolute inset-0" aria-hidden="true"><svg class="absolute bottom-0 left-0 h-[30%] w-full" viewBox="0 0 1200 300" preserveAspectRatio="none"><path fill="#1d2440" d="M0 150q150-70 300-20t300 10 300-30 300 10v180H0Z"/></svg></div>
  </div>
  <div class="relative z-[1] bg-[#1d2440] p-[clamp(28px,5vw,48px)] text-[#e8ebf7] [transform:translateZ(0)]">
    <h3 class="text-[1.3rem] font-bold text-white">Open from June to September</h3>
    <p class="mt-2.5 max-w-[60ch] text-[#c9cfe6]">Fjellstua is a staffed mountain hut on the Rondane ridge, a four-hour walk from the nearest road. Dinner is served at seven, breakfast at six for anyone heading to the summit for sunrise.</p>
    <ul class="mt-[22px] grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
      <li class="rounded-[14px] bg-[#2a3358] px-4 py-3.5 text-[.9rem] text-[#c9cfe6]"><strong class="block text-[1.25rem] text-[#ffd7a8]">38 beds</strong>In rooms of two to eight</li>
      <li class="rounded-[14px] bg-[#2a3358] px-4 py-3.5 text-[.9rem] text-[#c9cfe6]"><strong class="block text-[1.25rem] text-[#ffd7a8]">4 h</strong>Walk in from Spranget</li>
      <li class="rounded-[14px] bg-[#2a3358] px-4 py-3.5 text-[.9rem] text-[#c9cfe6]"><strong class="block text-[1.25rem] text-[#ffd7a8]">NOK 520</strong>Bed and breakfast</li>
    </ul>
  </div>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4d5f59] uppercase">Scroll-timeline hero: slow photo, faster card</span>
<section class="relative isolate h-[clamp(440px,72vh,620px)] overflow-hidden rounded-3xl bg-[#23313a]" data-plx-timeline="hero" aria-labelledby="plx-hero-title">
  <img class="absolute top-[-12%] left-0 -z-10 h-[124%] w-full object-cover" data-plx="hero-photo" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&q=75" alt="Snow-dusted peaks rising above a sea of cloud at dusk" width="1600" height="1067" loading="lazy">
  <span class="absolute top-[12%] right-[8%] rounded-full bg-white px-3.5 py-2.5 text-[.86rem] font-bold shadow-[0_14px_30px_-14px_rgba(0,0,0,.6)] [--plx-speed:1.6] max-sm:right-[5%]" data-plx="rise">1,240 m</span>
  <span class="absolute top-[34%] right-[22%] rounded-full bg-white px-3.5 py-2.5 text-[.86rem] font-bold shadow-[0_14px_30px_-14px_rgba(0,0,0,.6)] [--plx-speed:2.2] max-sm:hidden" data-plx="rise">Sunrise 04:12</span>
  <span class="absolute top-[20%] left-[6%] rounded-full bg-white px-3.5 py-2.5 text-[.86rem] font-bold shadow-[0_14px_30px_-14px_rgba(0,0,0,.6)] [--plx-speed:1.2]" data-plx="rise">38 beds</span>
  <div class="absolute bottom-[clamp(16px,5vw,56px)] left-[clamp(16px,5vw,56px)] max-w-[440px] rounded-[20px] bg-[rgba(10,20,26,.82)] p-[clamp(20px,3vw,30px)] text-[#f4f7f6] backdrop-blur-[6px] [--plx-speed:.8]" data-plx="rise">
    <p class="text-[.78rem] font-bold tracking-[.14em] text-[#ffd7a8] uppercase">Summer 2027</p>
    <h2 class="mt-2 text-[clamp(1.6rem,1.2rem+1.8vw,2.4rem)] leading-[1.08] font-bold tracking-[-.02em]" id="plx-hero-title">Sleep above the clouds on the Rondane ridge</h2>
    <p class="mt-2.5 text-[#d3dcda]">Bookings for June to September open on 1 February. Most weekends sell out within a week.</p>
  </div>
</section></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4d5f59] uppercase">Image cards with the photo drifting in its frame</span>
<ul class="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-5" aria-label="Day walks from the hut">
  <li class="overflow-hidden rounded-[20px] bg-white shadow-[0_20px_40px_-32px_rgba(16,35,30,.6)]"><div class="relative aspect-[4/5] overflow-hidden" data-plx-timeline="frame"><img class="absolute top-[-10%] left-0 h-[120%] w-full object-cover" data-plx="frame-photo" src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=700&q=75" alt="Green valley with a river below snow-capped mountains" width="700" height="875" loading="lazy"></div><div class="px-5 pt-[18px] pb-[22px]"><h3 class="text-[1.08rem] font-bold">Valley ridge loop</h3><p class="mt-1 text-[.9rem] text-[#4d5f59]">14 km · 6 hours · moderate</p></div></li>
  <li class="overflow-hidden rounded-[20px] bg-white shadow-[0_20px_40px_-32px_rgba(16,35,30,.6)]"><div class="relative aspect-[4/5] overflow-hidden" data-plx-timeline="frame"><img class="absolute top-[-10%] left-0 h-[120%] w-full object-cover" data-plx="frame-photo" src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?auto=format&fit=crop&w=700&q=75" alt="Jagged peaks reflected in a still glacial lake at sunrise" width="700" height="875" loading="lazy"></div><div class="px-5 pt-[18px] pb-[22px]"><h3 class="text-[1.08rem] font-bold">Mirror lake</h3><p class="mt-1 text-[.9rem] text-[#4d5f59]">9 km · 3 hours · easy</p></div></li>
  <li class="overflow-hidden rounded-[20px] bg-white shadow-[0_20px_40px_-32px_rgba(16,35,30,.6)]"><div class="relative aspect-[4/5] overflow-hidden" data-plx-timeline="frame"><img class="absolute top-[-10%] left-0 h-[120%] w-full object-cover" data-plx="frame-photo" src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=700&q=75" alt="A small boat crossing a turquoise lake between steep cliffs" width="700" height="875" loading="lazy"></div><div class="px-5 pt-[18px] pb-[22px]"><h3 class="text-[1.08rem] font-bold">Boat to the north shore</h3><p class="mt-1 text-[.9rem] text-[#4d5f59]">6 km · 2 hours · easy</p></div></li>
</ul></div>
CSS
/* The 3D scene is utilities only: perspective on the scroll container, translateZ() plus a matching scale() on
   each layer, and motion-reduce: variants that flatten it. The hero and cards use the shared scroll-driven CSS
   below, inside @supports, so other browsers show the same layout standing still. */
@keyframes plx-photo{from{transform:translateY(-9%)}to{transform:translateY(9%)}}
@keyframes plx-rise{from{transform:translateY(calc(var(--plx-speed,1) * 70px))}to{transform:translateY(calc(var(--plx-speed,1) * -70px))}}
@supports (animation-timeline:view()){
  @media (prefers-reduced-motion:no-preference){
    [data-plx-timeline="hero"]{view-timeline:--plx-hero block}
    [data-plx-timeline="frame"]{view-timeline:--plx-frame block}
    [data-plx="hero-photo"]{animation:plx-photo linear both;animation-timeline:--plx-hero}
    [data-plx="frame-photo"]{animation:plx-photo linear both;animation-timeline:--plx-frame}
    [data-plx="rise"]{animation:plx-rise linear both;animation-timeline:--plx-hero}
  }
}

About this set

Three parallax techniques for a mountain hut website. The first is a CSS-only 3D scene inside its own scroll container: the container has a 300px perspective, and the sky, the sun, two mountain ridges and the title sit further back with translateZ() and are scaled up to keep their size, so the deeper a layer is, the slower it moves when you scroll inside the box. An info panel then slides up over the whole scene. The second is a photo hero on the page where a scroll-driven animation moves the photo slowly while the text card and three badges rise faster, each at its own speed set by –plx-speed. The third is a row of trail cards where each photo drifts inside its frame as the card passes through the window. Use the 3D scene for a contained showcase, the hero for the top of a landing page and the cards for listings. The scroll-driven parts sit behind @supports, so other browsers show the same layout standing still, and the 3D scene works wherever 3D transforms do.

What’s included

  • CSS-only 3D perspective parallax with translateZ() layers
  • Scroll-timeline hero with a slow photo and faster card and badges
  • Image cards with the photo drifting inside its frame
  • Per-element speed through a --plx-speed custom property
  • Static layout as the fallback and under reduced motion

Accessibility

Decorative layers are aria-hidden, photos have alt text and the hero is a section named by its heading. Text sits on solid or near-solid panels, so its contrast does not depend on where a layer has moved. The 3D scene is a labelled region that takes keyboard focus with a visible outline. Under prefers-reduced-motion the perspective is removed and every layer stands still.

Customise it

Change the perspective on .plx-scene and the translateZ() and scale() pairs on each layer (scale = 1 + depth / perspective). Set --plx-speed on any hero element to change how fast it moves, and the colours of the sky gradient and SVG ridges for a different time of day.