You want depth and movement without JavaScript. By the end of this walkthrough you will build a layered parallax scene where mountains, a sun, and content scroll at different speeds using only CSS. The technique uses perspective and 3D transforms, which are smooth on modern browsers and friendly to mobile when built with care.
Why Parallax Scrolling Matters
Parallax adds depth that guides attention and makes long pages feel alive. Many tutorials lean on JavaScript scroll handlers, which bring complexity and jank under load. Pure CSS parallax keeps the effect on the compositor, avoids layout thrash, and stays maintainable. You also gain theming power through CSS variables, and you can mix in CSS shapes without image assets, which keeps bundle size down.
Prerequisites
You do not need advanced animation libraries for this. A grasp of 3D transforms and stacking will help, but we will cover the critical pieces along the way.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The layout uses a scroll container with perspective and two scenes: a parallax hero and a content section. Layers inside each scene sit at different Z depths. The base layer holds readable content; background layers are decorative and positioned with absolute coordinates.
<!-- HTML -->
<div class="parallax">
<section class="group group--hero" aria-label="Mountain sunrise">
<div class="layer layer--deep" aria-hidden="true">
<div class="sky"></div>
<div class="sun" aria-hidden="true"></div>
</div>
<div class="layer layer--back" aria-hidden="true">
<div class="mountain mountain--left"></div>
<div class="mountain mountain--right"></div>
</div>
<div class="layer layer--base">
<header class="hero-content">
<h1>Parallax Scrolling with Pure CSS</h1>
<p>Layers at different depths create a smooth 3D scroll effect. Scroll down to see it in action.</p>
</header>
</div>
</section>
<section class="group group--content" aria-label="Article content">
<div class="layer layer--base">
<article class="content">
<h2>Readable Content on the Base Layer</h2>
<p>This section demonstrates typical page copy. The base layer sits at translateZ(0), so text remains crisp and does not scale as you scroll.</p>
<p>You can add more sections below using the same group pattern, or stitch scenes together with a decorative divider.</p>
</article>
</div>
</section>
</div>
Step 2: The Basic CSS & Styling
Start with variables for color and spacing. The parallax container gains a fixed viewport height, vertical scrolling, and perspective. The perspective value is small, which exaggerates depth when layers translate along Z.
/* CSS */
:root {
--bg: #0f172a; /* page background */
--text: #e2e8f0; /* body text */
--muted: #94a3b8; /* supporting text */
--sky-top: #5eead4; /* gradient top */
--sky-bottom: #0ea5e9; /* gradient bottom */
--sun: #fde047; /* sun color */
--mountain-1: #0b5d4c; /* far mountain */
--mountain-2: #136f63; /* near mountain */
--content-bg: #0b1220; /* base content bg */
--maxw: 72ch;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
margin: 0;
font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.parallax {
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px; /* key: create depth */
perspective-origin: 50% 50%;
transform-style: preserve-3d; /* keep nested transforms in 3D space */
}
h1, h2, h3 {
line-height: 1.2;
margin: 0 0 .6rem;
}
p {
margin: 0 0 1rem;
color: var(--muted);
}
Advanced Tip: With CSS variables you can theme the entire scene by swapping a few values in :root, or by scoping a theme class on .parallax. Keep depth constants as variables too if you plan to retune speed later.
Step 3: Building the Parallax Layers
Each group is a 3D stage. Background layers stretch edge to edge and sit at negative Z to move slower than the scroll. The base layer stays at Z 0 and holds text. Scaling compensates for perspective shrinking so background layers still fill the viewport.
/* CSS */
.group {
position: relative;
height: 120vh; /* taller than viewport for visible motion */
transform-style: preserve-3d;
}
.layer {
position: absolute;
inset: 0;
transform-origin: center;
will-change: transform; /* hint: transforms only */
}
.layer--deep {
transform: translateZ(-2px) scale(3); /* slowest */
}
.layer--back {
transform: translateZ(-1px) scale(2); /* mid speed */
}
.layer--base {
position: relative; /* keep content in normal flow */
transform: translateZ(0);
display: grid;
place-items: center;
}
.hero-content {
width: min(90vw, var(--maxw));
padding: 2rem 1.25rem 3rem;
background: color-mix(in oklab, var(--content-bg), transparent 30%);
border: 1px solid color-mix(in oklab, var(--content-bg), white 80%);
border-radius: 16px;
backdrop-filter: blur(2px);
text-align: center;
}
.group--content {
height: auto; /* content section can be normal height */
padding: 6rem 0;
}
.group--content .content {
width: min(90vw, var(--maxw));
margin: 0 auto;
}
How This Works (Code Breakdown)
The .parallax element sets perspective: 1px on the scroll container. As the user scrolls, the browser projects children that have translateZ() in true 3D space. A small perspective increases the effect, so translateZ(-2px) appears much further away than translateZ(-1px).
Each .group forms a stage with transform-style: preserve-3d so children keep their depth. Background layers are positioned with inset: 0, which pins them to all four edges. Because elements at negative Z would get smaller with perspective, scale() grows them to keep coverage. The exact scale is not magic; you only need enough to cover the viewport through the scroll range.
The base layer uses position: relative so that actual content remains in the normal flow and can be centered with grid. This is the layer for headings, buttons, and any interactive UI. Background layers are marked aria-hidden=”true” in the HTML because they are decorative and should not be read by assistive tech.
Step 4: Building the Scene Elements
Now add the sky gradient, a sun as a circle, and two mountains made from triangles. Using CSS shapes keeps the scene light. If you want a refresher on circles or triangles, see how to make a circle with CSS and how to craft a triangle up with CSS. We will place these shapes on appropriate layers to emphasize depth.
/* CSS */
.sky {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, var(--sky-top), var(--sky-bottom));
}
.sun {
position: absolute;
width: 18vmin;
height: 18vmin;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff7b1, var(--sun) 60%, #fbbf24 100%);
top: 15vh;
left: 12vw;
box-shadow: 0 0 60px 10px rgba(253, 224, 71, .25);
}
.mountain {
position: absolute;
bottom: -2vh; /* extend a bit below to avoid edges during scroll */
width: 0;
height: 0;
}
.mountain--left {
left: 8vw;
border-left: 12vw solid transparent;
border-right: 12vw solid transparent;
border-bottom: 22vw solid var(--mountain-1);
filter: drop-shadow(0 14px 18px rgba(0,0,0,.25));
}
.mountain--right {
right: 6vw;
border-left: 14vw solid transparent;
border-right: 14vw solid transparent;
border-bottom: 26vw solid var(--mountain-2);
filter: drop-shadow(0 18px 24px rgba(0,0,0,.28));
}
How This Works (Code Breakdown)
The sky is a simple gradient that fills the deep layer. Because the deep layer uses translateZ(-2px) scale(3), it drifts slowest, just like a far horizon. The sun is a circle built with border-radius: 50% and a radial gradient. This mirrors the approach used when you make a circle with CSS, but with color stops to add glow. A box-shadow adds a soft halo.
The mountains use the border triangle trick: set width and height to zero, then form two transparent side borders and a solid bottom border. That is the technique behind a triangle up with CSS. The two mountains sit on the back layer, which makes them move faster than the sky but slower than the content. Minor drop-shadows help the near mountain stand out.
Be mindful of scale interaction: triangles sit on a layer that already scales because of translateZ negative depth. The scaling keeps them oversized, so they cover the bottom edge even while scrolling.
Advanced Techniques: Adding Animations & Hover Effects
A gentle float on the sun adds life without distracting from the content. The animation runs only when the user does not prefer reduced motion. You can also add a low-amplitude background drift to the sky.
/* CSS */
@keyframes float {
0% { transform: translateY(0); }
50% { transform: translateY(-1.2vh); }
100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: no-preference) {
.layer--deep .sun {
animation: float 10s ease-in-out infinite;
}
.layer--deep .sky {
background-position: 0 0;
background-size: 100% 100%;
animation: sky-pan 60s linear infinite;
}
@keyframes sky-pan {
to { filter: hue-rotate(10deg); }
}
}
/* Optional: subtle tilt on hover for desktop pointers */
@media (hover: hover) and (pointer: fine) {
.group--hero:hover .mountain--right {
filter: drop-shadow(0 22px 28px rgba(0,0,0,.32));
}
}
The float keyframes move the sun up and down along Y, which the compositor handles without layout. The prefers-reduced-motion check gates this effect for users who avoid movement. The sky gets a very slow hue shift for a dawn-to-noon feel; keep amplitude low to avoid color banding.
If you want a divider between scenes, a soft wave blends sections without a hard edge. A ready approach is the wave page divider technique described here: CSS wave page divider. Place the wave at the bottom of the hero base layer or as a top decoration on the next group.
Accessibility & Performance
Parallax can either help or hinder. A few guardrails keep it friendly.
Accessibility
Maintain logical reading order by placing actual text content on the base layer at Z 0. Decorative layers get aria-hidden=”true” so screen readers skip them. Headings follow a clean hierarchy so users can navigate the document. Motion meets user preference by guarding animations with prefers-reduced-motion media queries. Avoid parallax on critical interactive surfaces like forms or menus, so users are not fighting movement.
Performance
CSS transforms are handled on the compositor, which is fast for scroll-bound motion. Keep parallax layers free of properties that trigger layout or paint on every frame. Gradients and border triangles are cheap compared to large images and blurs. If you use real images, compress them and favor AVIF or WebP with appropriate sizes. Will-change gives the compositor a hint but avoid spraying it across many elements. The translateZ negative values plus scale are stable on modern engines and avoid the mobile quirks that background-attachment: fixed has on iOS. Test the scene on low-end devices and reduce the number of layers if frames drop.
Bring Your Scenes to Life
You built a parallax scroll scene with true 3D depth using only CSS. The hero blends a sky, a floating sun, and mountain triangles while the content remains crisp on the base layer. Mix in more shapes from your toolkit, from a simple circle sun to layered peaks, or drop in a wave divider to bridge sections. Now you have a reliable pattern for any landing, article header, or storytelling page that calls for depth without JavaScript.