08 / 22
All 22 in 22 CSS Parallax Effects
- 01Pure CSS Parallax Hero with Scroll Timeline
- 02Animation Timeline View Element Enter Parallax
- 03Scroll Driven SVG Path Draw Parallax
- 04Split Screen Dual Axis Parallax Scroll
- 05Backdrop Filter Blur Cross Fade Parallax
- 06Scroll Linked Typography Scale Parallax
- 07Mouse Position Parallax Card Grid
- 083D Transform Perspective Layer Parallax
- 09Parallax Product Showcase with Pinned Scene
- 10Parallax Storytelling with Chapter Markers
- 11Scroll Driven Aurora Gradient Parallax
- 12Parallax Landing Page Hero with Depth Layers
- 13CSS Parallax Hero Section with Layered Scroll
- 14Multi Layer Parallax Landscape Illustration
- 15Sticky Parallax Sections Stripe Linear Style
- 16Multi Scene Parallax Scrolling Journey
- 17Parallax Image Grid Asymmetric Gallery
- 18Horizontal Parallax Scroll Panels
- 19Parallax Text Overlay Texture Effect
- 20Parallax Card Hover with Mouse Tracking
- 21Zoom In Depth Parallax on Scroll
- 22Parallax Background Blur Transition
3D Transform Perspective Layer Parallax
The canonical perspective-based parallax, rebuilt as a documented reference: a scroll container with perspective: 1px, layers pushed back with translateZ and scaled up to compensate, and an annotation panel that names what each declaration is doing. No scroll listener and no scroll-timeline — the depth is a property of the 3D scene itself.
Build UI like this from scratch: Flexbox, Grid, animations & responsive CSS
ZTM · The CSS Bootcamp · 30‑day money-back guarantee · Affiliate link
The code
<div class="plx-08">
<div class="plx-08__viewport">
<div class="plx-08__scene">
<div class="plx-08__group plx-08__group--back" aria-hidden="true">
<div class="plx-08__stars"></div>
</div>
<div class="plx-08__group plx-08__group--mid" aria-hidden="true">
<div class="plx-08__mesh"></div>
</div>
<div class="plx-08__group plx-08__group--front">
<div class="plx-08__panel">
<p class="plx-08__prompt">~/parallax $ cat perspective.css</p>
<h1 class="plx-08__h1">perspective: 1px</h1>
<pre class="plx-08__code">.viewport { perspective: 1px; overflow-y: auto; }
.back { transform: translateZ(-2px) scale(3); }
.front { transform: translateZ(0); }</pre>
<p class="plx-08__note">Depth ratio = 1 + |z| / perspective. At z = -2px under a 1px camera the back plane renders at 1/3 size and moves at 1/3 the rate, so it is scaled by 3 to fill the frame again.</p>
</div>
</div>
<section class="plx-08__doc">
<h2 class="plx-08__h2">Why this still ships</h2>
<p class="plx-08__p">No listener, no timeline, no feature query. The browser projects a fixed 3D scene and scrolling moves the camera through it — support goes back to 2013, which still matters for long-tail Safari.</p>
<h2 class="plx-08__h2">What it costs</h2>
<p class="plx-08__p">The perspective ancestor becomes a containing block. Anything position: fixed inside this scene anchors here, not to the viewport, and sticky children behave unpredictably. Scroll this inner container, not the page.</p>
<dl class="plx-08__table">
<div class="plx-08__row"><dt>z = 0</dt><dd>1.0x rate · foreground</dd></div>
<div class="plx-08__row"><dt>z = -1px</dt><dd>0.5x rate · scale(2)</dd></div>
<div class="plx-08__row"><dt>z = -2px</dt><dd>0.33x rate · scale(3)</dd></div>
</dl>
</section>
</div>
</div>
</div><div class="plx-08">
<div class="plx-08__viewport">
<div class="plx-08__scene">
<div class="plx-08__group plx-08__group--back" aria-hidden="true">
<div class="plx-08__stars"></div>
</div>
<div class="plx-08__group plx-08__group--mid" aria-hidden="true">
<div class="plx-08__mesh"></div>
</div>
<div class="plx-08__group plx-08__group--front">
<div class="plx-08__panel">
<p class="plx-08__prompt">~/parallax $ cat perspective.css</p>
<h1 class="plx-08__h1">perspective: 1px</h1>
<pre class="plx-08__code">.viewport { perspective: 1px; overflow-y: auto; }
.back { transform: translateZ(-2px) scale(3); }
.front { transform: translateZ(0); }</pre>
<p class="plx-08__note">Depth ratio = 1 + |z| / perspective. At z = -2px under a 1px camera the back plane renders at 1/3 size and moves at 1/3 the rate, so it is scaled by 3 to fill the frame again.</p>
</div>
</div>
<section class="plx-08__doc">
<h2 class="plx-08__h2">Why this still ships</h2>
<p class="plx-08__p">No listener, no timeline, no feature query. The browser projects a fixed 3D scene and scrolling moves the camera through it — support goes back to 2013, which still matters for long-tail Safari.</p>
<h2 class="plx-08__h2">What it costs</h2>
<p class="plx-08__p">The perspective ancestor becomes a containing block. Anything position: fixed inside this scene anchors here, not to the viewport, and sticky children behave unpredictably. Scroll this inner container, not the page.</p>
<dl class="plx-08__table">
<div class="plx-08__row"><dt>z = 0</dt><dd>1.0x rate · foreground</dd></div>
<div class="plx-08__row"><dt>z = -1px</dt><dd>0.5x rate · scale(2)</dd></div>
<div class="plx-08__row"><dt>z = -2px</dt><dd>0.33x rate · scale(3)</dd></div>
</dl>
</section>
</div>
</div>
</div>.plx-08 {
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
--font-display: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
--bg: #04070a;
--ink: #b8ffd0;
--dim: #4f9c72;
--accent: #7dff9b;
background: var(--bg);
color: var(--ink);
font-family: var(--font-display);
}
.plx-08 * {
box-sizing: border-box;
}
.plx-08__viewport {
block-size: 100svh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px;
perspective-origin: 0 0;
-webkit-overflow-scrolling: touch;
background: var(--bg);
}
.plx-08__scene {
position: relative;
transform-style: preserve-3d;
}
.plx-08__group {
position: absolute;
inset: 0;
transform-style: preserve-3d;
pointer-events: none;
}
.plx-08__group--back {
transform: translateZ(-2px) scale(3);
transform-origin: 0 0;
}
.plx-08__group--mid {
transform: translateZ(-1px) scale(2);
transform-origin: 0 0;
}
.plx-08__group--front {
position: relative;
transform: translateZ(0);
pointer-events: auto;
}
.plx-08__stars {
block-size: 100svh;
background-image: radial-gradient(1.5px 1.5px at 12% 22%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 68% 14%, #3ddc84 40%, transparent 42%), radial-gradient(1.5px 1.5px at 34% 62%, #b8ffd0 40%, transparent 42%), radial-gradient(1.5px 1.5px at 86% 48%, #45a86a 40%, transparent 42%), radial-gradient(1.5px 1.5px at 52% 84%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 22% 92%, #3ddc84 40%, transparent 42%);
}
.plx-08__mesh {
block-size: 100svh;
background-image: linear-gradient(rgba(125,255,155,0.14) 1px, transparent 1px), linear-gradient(90deg, rgba(125,255,155,0.14) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: linear-gradient(to bottom, transparent 12%, #000 58%, transparent 96%);
}
.plx-08__group--front {
padding: clamp(16px, 4vw, 44px);
min-block-size: 100svh;
display: flex;
align-items: center;
}
.plx-08__panel {
inline-size: 100%;
max-inline-size: 46rem;
min-inline-size: 0;
padding: clamp(14px, 3vw, 24px);
border: 1px solid #1d5238;
background: rgba(4,10,7,0.86);
position: relative;
}
.plx-08__panel::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(125,255,155,0.05) 0 1px, transparent 1px 3px);
}
.plx-08__prompt {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--dim);
letter-spacing: 0.04em;
}
.plx-08__h1 {
margin: 0 0 14px;
font-size: clamp(22px, 5vw, 40px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--accent);
}
.plx-08__code {
margin: 0 0 14px;
padding: 12px;
overflow-x: auto;
border-inline-start: 2px solid var(--accent);
background: #071310;
font-size: 12.5px;
line-height: 1.7;
color: #c9ffdb;
}
.plx-08__note {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--dim);
max-inline-size: 34rem;
}
.plx-08__doc {
position: relative;
background: var(--bg);
padding: clamp(22px, 5vw, 56px) clamp(16px, 4vw, 44px) 90px;
border-top: 1px solid #17402c;
}
.plx-08__h2 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.plx-08__p {
margin: 0 0 22px;
max-inline-size: 42rem;
font-size: 13px;
line-height: 1.75;
color: #86c9a3;
}
.plx-08__table {
margin: 0;
display: grid;
gap: 6px;
max-inline-size: 30rem;
}
.plx-08__row {
display: flex;
gap: 12px;
justify-content: space-between;
border-bottom: 1px dashed #1d5238;
padding-block: 7px;
min-inline-size: 0;
}
.plx-08__row dt {
font-size: 12.5px;
color: var(--accent);
}
.plx-08__row dd {
margin: 0;
font-size: 12.5px;
color: var(--dim);
text-align: end;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.plx-08__viewport {
perspective: none;
}
.plx-08__group {
position: relative;
inset: auto;
transform: none !important;
}
.plx-08__group--back,
.plx-08__group--mid {
display: none;
}
}.plx-08 {
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
--font-display: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
--bg: #04070a;
--ink: #b8ffd0;
--dim: #4f9c72;
--accent: #7dff9b;
background: var(--bg);
color: var(--ink);
font-family: var(--font-display);
}
.plx-08 * {
box-sizing: border-box;
}
.plx-08__viewport {
block-size: 100svh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px;
perspective-origin: 0 0;
-webkit-overflow-scrolling: touch;
background: var(--bg);
}
.plx-08__scene {
position: relative;
transform-style: preserve-3d;
}
.plx-08__group {
position: absolute;
inset: 0;
transform-style: preserve-3d;
pointer-events: none;
}
.plx-08__group--back {
transform: translateZ(-2px) scale(3);
transform-origin: 0 0;
}
.plx-08__group--mid {
transform: translateZ(-1px) scale(2);
transform-origin: 0 0;
}
.plx-08__group--front {
position: relative;
transform: translateZ(0);
pointer-events: auto;
}
.plx-08__stars {
block-size: 100svh;
background-image: radial-gradient(1.5px 1.5px at 12% 22%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 68% 14%, #3ddc84 40%, transparent 42%), radial-gradient(1.5px 1.5px at 34% 62%, #b8ffd0 40%, transparent 42%), radial-gradient(1.5px 1.5px at 86% 48%, #45a86a 40%, transparent 42%), radial-gradient(1.5px 1.5px at 52% 84%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 22% 92%, #3ddc84 40%, transparent 42%);
}
.plx-08__mesh {
block-size: 100svh;
background-image: linear-gradient(rgba(125,255,155,0.14) 1px, transparent 1px), linear-gradient(90deg, rgba(125,255,155,0.14) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: linear-gradient(to bottom, transparent 12%, #000 58%, transparent 96%);
}
.plx-08__group--front {
padding: clamp(16px, 4vw, 44px);
min-block-size: 100svh;
display: flex;
align-items: center;
}
.plx-08__panel {
inline-size: 100%;
max-inline-size: 46rem;
min-inline-size: 0;
padding: clamp(14px, 3vw, 24px);
border: 1px solid #1d5238;
background: rgba(4,10,7,0.86);
position: relative;
}
.plx-08__panel::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(125,255,155,0.05) 0 1px, transparent 1px 3px);
}
.plx-08__prompt {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--dim);
letter-spacing: 0.04em;
}
.plx-08__h1 {
margin: 0 0 14px;
font-size: clamp(22px, 5vw, 40px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--accent);
}
.plx-08__code {
margin: 0 0 14px;
padding: 12px;
overflow-x: auto;
border-inline-start: 2px solid var(--accent);
background: #071310;
font-size: 12.5px;
line-height: 1.7;
color: #c9ffdb;
}
.plx-08__note {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--dim);
max-inline-size: 34rem;
}
.plx-08__doc {
position: relative;
background: var(--bg);
padding: clamp(22px, 5vw, 56px) clamp(16px, 4vw, 44px) 90px;
border-top: 1px solid #17402c;
}
.plx-08__h2 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.plx-08__p {
margin: 0 0 22px;
max-inline-size: 42rem;
font-size: 13px;
line-height: 1.75;
color: #86c9a3;
}
.plx-08__table {
margin: 0;
display: grid;
gap: 6px;
max-inline-size: 30rem;
}
.plx-08__row {
display: flex;
gap: 12px;
justify-content: space-between;
border-bottom: 1px dashed #1d5238;
padding-block: 7px;
min-inline-size: 0;
}
.plx-08__row dt {
font-size: 12.5px;
color: var(--accent);
}
.plx-08__row dd {
margin: 0;
font-size: 12.5px;
color: var(--dim);
text-align: end;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.plx-08__viewport {
perspective: none;
}
.plx-08__group {
position: relative;
inset: auto;
transform: none !important;
}
.plx-08__group--back,
.plx-08__group--mid {
display: none;
}
}Here's a working CSS Parallax Effect 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: 3D Transform Perspective Layer Parallax
Source: https://codefronts.com/motion/css-parallax-designs/3d-transform-perspective-layer-parallax/
The canonical perspective-based parallax, rebuilt as a documented reference: a scroll container with perspective: 1px, layers pushed back with translateZ and scaled up to compensate, and an annotation panel that names what each declaration is doing. No scroll listener and no scroll-timeline — the depth is a property of the 3D scene itself.
## HTML
```html
<div class="plx-08">
<div class="plx-08__viewport">
<div class="plx-08__scene">
<div class="plx-08__group plx-08__group--back" aria-hidden="true">
<div class="plx-08__stars"></div>
</div>
<div class="plx-08__group plx-08__group--mid" aria-hidden="true">
<div class="plx-08__mesh"></div>
</div>
<div class="plx-08__group plx-08__group--front">
<div class="plx-08__panel">
<p class="plx-08__prompt">~/parallax $ cat perspective.css</p>
<h1 class="plx-08__h1">perspective: 1px</h1>
<pre class="plx-08__code">.viewport { perspective: 1px; overflow-y: auto; }
.back { transform: translateZ(-2px) scale(3); }
.front { transform: translateZ(0); }</pre>
<p class="plx-08__note">Depth ratio = 1 + |z| / perspective. At z = -2px under a 1px camera the back plane renders at 1/3 size and moves at 1/3 the rate, so it is scaled by 3 to fill the frame again.</p>
</div>
</div>
<section class="plx-08__doc">
<h2 class="plx-08__h2">Why this still ships</h2>
<p class="plx-08__p">No listener, no timeline, no feature query. The browser projects a fixed 3D scene and scrolling moves the camera through it — support goes back to 2013, which still matters for long-tail Safari.</p>
<h2 class="plx-08__h2">What it costs</h2>
<p class="plx-08__p">The perspective ancestor becomes a containing block. Anything position: fixed inside this scene anchors here, not to the viewport, and sticky children behave unpredictably. Scroll this inner container, not the page.</p>
<dl class="plx-08__table">
<div class="plx-08__row"><dt>z = 0</dt><dd>1.0x rate · foreground</dd></div>
<div class="plx-08__row"><dt>z = -1px</dt><dd>0.5x rate · scale(2)</dd></div>
<div class="plx-08__row"><dt>z = -2px</dt><dd>0.33x rate · scale(3)</dd></div>
</dl>
</section>
</div>
</div>
</div>
```
## CSS
```css
.plx-08 {
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
--font-display: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
--bg: #04070a;
--ink: #b8ffd0;
--dim: #4f9c72;
--accent: #7dff9b;
background: var(--bg);
color: var(--ink);
font-family: var(--font-display);
}
.plx-08 * {
box-sizing: border-box;
}
.plx-08__viewport {
block-size: 100svh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px;
perspective-origin: 0 0;
-webkit-overflow-scrolling: touch;
background: var(--bg);
}
.plx-08__scene {
position: relative;
transform-style: preserve-3d;
}
.plx-08__group {
position: absolute;
inset: 0;
transform-style: preserve-3d;
pointer-events: none;
}
.plx-08__group--back {
transform: translateZ(-2px) scale(3);
transform-origin: 0 0;
}
.plx-08__group--mid {
transform: translateZ(-1px) scale(2);
transform-origin: 0 0;
}
.plx-08__group--front {
position: relative;
transform: translateZ(0);
pointer-events: auto;
}
.plx-08__stars {
block-size: 100svh;
background-image: radial-gradient(1.5px 1.5px at 12% 22%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 68% 14%, #3ddc84 40%, transparent 42%), radial-gradient(1.5px 1.5px at 34% 62%, #b8ffd0 40%, transparent 42%), radial-gradient(1.5px 1.5px at 86% 48%, #45a86a 40%, transparent 42%), radial-gradient(1.5px 1.5px at 52% 84%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 22% 92%, #3ddc84 40%, transparent 42%);
}
.plx-08__mesh {
block-size: 100svh;
background-image: linear-gradient(rgba(125,255,155,0.14) 1px, transparent 1px), linear-gradient(90deg, rgba(125,255,155,0.14) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: linear-gradient(to bottom, transparent 12%, #000 58%, transparent 96%);
}
.plx-08__group--front {
padding: clamp(16px, 4vw, 44px);
min-block-size: 100svh;
display: flex;
align-items: center;
}
.plx-08__panel {
inline-size: 100%;
max-inline-size: 46rem;
min-inline-size: 0;
padding: clamp(14px, 3vw, 24px);
border: 1px solid #1d5238;
background: rgba(4,10,7,0.86);
position: relative;
}
.plx-08__panel::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(125,255,155,0.05) 0 1px, transparent 1px 3px);
}
.plx-08__prompt {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--dim);
letter-spacing: 0.04em;
}
.plx-08__h1 {
margin: 0 0 14px;
font-size: clamp(22px, 5vw, 40px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--accent);
}
.plx-08__code {
margin: 0 0 14px;
padding: 12px;
overflow-x: auto;
border-inline-start: 2px solid var(--accent);
background: #071310;
font-size: 12.5px;
line-height: 1.7;
color: #c9ffdb;
}
.plx-08__note {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--dim);
max-inline-size: 34rem;
}
.plx-08__doc {
position: relative;
background: var(--bg);
padding: clamp(22px, 5vw, 56px) clamp(16px, 4vw, 44px) 90px;
border-top: 1px solid #17402c;
}
.plx-08__h2 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.plx-08__p {
margin: 0 0 22px;
max-inline-size: 42rem;
font-size: 13px;
line-height: 1.75;
color: #86c9a3;
}
.plx-08__table {
margin: 0;
display: grid;
gap: 6px;
max-inline-size: 30rem;
}
.plx-08__row {
display: flex;
gap: 12px;
justify-content: space-between;
border-bottom: 1px dashed #1d5238;
padding-block: 7px;
min-inline-size: 0;
}
.plx-08__row dt {
font-size: 12.5px;
color: var(--accent);
}
.plx-08__row dd {
margin: 0;
font-size: 12.5px;
color: var(--dim);
text-align: end;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.plx-08__viewport {
perspective: none;
}
.plx-08__group {
position: relative;
inset: auto;
transform: none !important;
}
.plx-08__group--back,
.plx-08__group--mid {
display: none;
}
}
```Here's a working CSS Parallax Effect 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: 3D Transform Perspective Layer Parallax
Source: https://codefronts.com/motion/css-parallax-designs/3d-transform-perspective-layer-parallax/
The canonical perspective-based parallax, rebuilt as a documented reference: a scroll container with perspective: 1px, layers pushed back with translateZ and scaled up to compensate, and an annotation panel that names what each declaration is doing. No scroll listener and no scroll-timeline — the depth is a property of the 3D scene itself.
## HTML
```html
<div class="plx-08">
<div class="plx-08__viewport">
<div class="plx-08__scene">
<div class="plx-08__group plx-08__group--back" aria-hidden="true">
<div class="plx-08__stars"></div>
</div>
<div class="plx-08__group plx-08__group--mid" aria-hidden="true">
<div class="plx-08__mesh"></div>
</div>
<div class="plx-08__group plx-08__group--front">
<div class="plx-08__panel">
<p class="plx-08__prompt">~/parallax $ cat perspective.css</p>
<h1 class="plx-08__h1">perspective: 1px</h1>
<pre class="plx-08__code">.viewport { perspective: 1px; overflow-y: auto; }
.back { transform: translateZ(-2px) scale(3); }
.front { transform: translateZ(0); }</pre>
<p class="plx-08__note">Depth ratio = 1 + |z| / perspective. At z = -2px under a 1px camera the back plane renders at 1/3 size and moves at 1/3 the rate, so it is scaled by 3 to fill the frame again.</p>
</div>
</div>
<section class="plx-08__doc">
<h2 class="plx-08__h2">Why this still ships</h2>
<p class="plx-08__p">No listener, no timeline, no feature query. The browser projects a fixed 3D scene and scrolling moves the camera through it — support goes back to 2013, which still matters for long-tail Safari.</p>
<h2 class="plx-08__h2">What it costs</h2>
<p class="plx-08__p">The perspective ancestor becomes a containing block. Anything position: fixed inside this scene anchors here, not to the viewport, and sticky children behave unpredictably. Scroll this inner container, not the page.</p>
<dl class="plx-08__table">
<div class="plx-08__row"><dt>z = 0</dt><dd>1.0x rate · foreground</dd></div>
<div class="plx-08__row"><dt>z = -1px</dt><dd>0.5x rate · scale(2)</dd></div>
<div class="plx-08__row"><dt>z = -2px</dt><dd>0.33x rate · scale(3)</dd></div>
</dl>
</section>
</div>
</div>
</div>
```
## CSS
```css
.plx-08 {
width: 100%;
min-height: 100vh;
display: block;
position: relative;
box-sizing: border-box;
--font-display: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
--bg: #04070a;
--ink: #b8ffd0;
--dim: #4f9c72;
--accent: #7dff9b;
background: var(--bg);
color: var(--ink);
font-family: var(--font-display);
}
.plx-08 * {
box-sizing: border-box;
}
.plx-08__viewport {
block-size: 100svh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px;
perspective-origin: 0 0;
-webkit-overflow-scrolling: touch;
background: var(--bg);
}
.plx-08__scene {
position: relative;
transform-style: preserve-3d;
}
.plx-08__group {
position: absolute;
inset: 0;
transform-style: preserve-3d;
pointer-events: none;
}
.plx-08__group--back {
transform: translateZ(-2px) scale(3);
transform-origin: 0 0;
}
.plx-08__group--mid {
transform: translateZ(-1px) scale(2);
transform-origin: 0 0;
}
.plx-08__group--front {
position: relative;
transform: translateZ(0);
pointer-events: auto;
}
.plx-08__stars {
block-size: 100svh;
background-image: radial-gradient(1.5px 1.5px at 12% 22%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 68% 14%, #3ddc84 40%, transparent 42%), radial-gradient(1.5px 1.5px at 34% 62%, #b8ffd0 40%, transparent 42%), radial-gradient(1.5px 1.5px at 86% 48%, #45a86a 40%, transparent 42%), radial-gradient(1.5px 1.5px at 52% 84%, #7dff9b 40%, transparent 42%), radial-gradient(1.5px 1.5px at 22% 92%, #3ddc84 40%, transparent 42%);
}
.plx-08__mesh {
block-size: 100svh;
background-image: linear-gradient(rgba(125,255,155,0.14) 1px, transparent 1px), linear-gradient(90deg, rgba(125,255,155,0.14) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: linear-gradient(to bottom, transparent 12%, #000 58%, transparent 96%);
}
.plx-08__group--front {
padding: clamp(16px, 4vw, 44px);
min-block-size: 100svh;
display: flex;
align-items: center;
}
.plx-08__panel {
inline-size: 100%;
max-inline-size: 46rem;
min-inline-size: 0;
padding: clamp(14px, 3vw, 24px);
border: 1px solid #1d5238;
background: rgba(4,10,7,0.86);
position: relative;
}
.plx-08__panel::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(125,255,155,0.05) 0 1px, transparent 1px 3px);
}
.plx-08__prompt {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--dim);
letter-spacing: 0.04em;
}
.plx-08__h1 {
margin: 0 0 14px;
font-size: clamp(22px, 5vw, 40px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--accent);
}
.plx-08__code {
margin: 0 0 14px;
padding: 12px;
overflow-x: auto;
border-inline-start: 2px solid var(--accent);
background: #071310;
font-size: 12.5px;
line-height: 1.7;
color: #c9ffdb;
}
.plx-08__note {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--dim);
max-inline-size: 34rem;
}
.plx-08__doc {
position: relative;
background: var(--bg);
padding: clamp(22px, 5vw, 56px) clamp(16px, 4vw, 44px) 90px;
border-top: 1px solid #17402c;
}
.plx-08__h2 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.plx-08__p {
margin: 0 0 22px;
max-inline-size: 42rem;
font-size: 13px;
line-height: 1.75;
color: #86c9a3;
}
.plx-08__table {
margin: 0;
display: grid;
gap: 6px;
max-inline-size: 30rem;
}
.plx-08__row {
display: flex;
gap: 12px;
justify-content: space-between;
border-bottom: 1px dashed #1d5238;
padding-block: 7px;
min-inline-size: 0;
}
.plx-08__row dt {
font-size: 12.5px;
color: var(--accent);
}
.plx-08__row dd {
margin: 0;
font-size: 12.5px;
color: var(--dim);
text-align: end;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.plx-08__viewport {
perspective: none;
}
.plx-08__group {
position: relative;
inset: auto;
transform: none !important;
}
.plx-08__group--back,
.plx-08__group--mid {
display: none;
}
}
```How this works
The whole trick is one number. With perspective: 1px on the scroll container, an element at translateZ(-1px) is exactly twice as far from the camera as one at translateZ(0), so it moves at half the rate when the container scrolls. Nothing is animating: the browser is projecting a static 3D scene, and scrolling changes the camera position. That is why this pattern has no JS and no timeline.
Every push back needs a scale forward. Moving a layer to translateZ(-2px) under a 1px perspective makes it render at one third size, so it is paired with scale(3). The general form is scale(1 + z / perspective) in absolute terms — get it wrong and the background is either cropped or floating in the middle of the frame. Put the two transforms in that order: translateZ() then scale().
The container must be the scroller, not the page. perspective only affects children of the element that carries it, so the scene lives inside a fixed-height overflow-y: auto box. This has one large consequence: a perspective value creates a containing block, so any position: fixed child — a sticky header, a modal — anchors to the scene instead of the viewport. That single side effect is the reason most sites moved to scroll-timeline for full-page work.
Read it as a reference, ship it where you need iOS support. The perspective technique works back to roughly 2013 and needs no gate at all, which still makes it the pragmatic choice for older Safari. Its cost is the containing-block behaviour above plus the awkward interaction with position: sticky inside the scene; demo 01 is the modern equivalent when neither matters.
Make it yours
- Change
translateZon the back group to move it further away — remember to update the pairedscale(). - Raise the container's
perspectivefrom1pxto2pxto halve the depth effect at the same Z values. - Add a third mid group at
translateZ(-1px) scale(2)for a fourth plane. - Swap
perspective-originto50% 50%to centre the vanishing point. - Retint the scanline overlay opacity for a stronger or subtler CRT texture.
- Set the scene height to
140svhfor a taller parallax band.
Gotchas — read before shipping
- A
perspectiveancestor creates a containing block, soposition: fixedchildren scroll with the scene instead of the viewport — the classic reason a sticky nav breaks inside this pattern. - Forgetting the compensating
scale()leaves the back layer visibly small and floating; the scale factor must track the Z value. - Transform order matters:
scale()beforetranslateZ()multiplies the Z distance and changes the depth you thought you set. - Under reduced motion, flatten the scene by removing
perspective— do not simply hide layers, or content disappears.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 36+ | 9+ | 16+ | 36+ |
This is the oldest-supported technique in the collection — 3D transforms and perspective have shipped for over a decade with no feature gate required. The only modern dependency is the custom properties used for theming.