EmpireUI
Get Pro
← Blog8 min read#parallax#css#perspective

CSS Parallax Without JavaScript: perspective, transform-style, Layers

Pure CSS parallax using perspective and transform-style — no scroll listeners, no JS overhead. Here's exactly how the layer trick works and where it breaks.

abstract layered geometric shapes with depth and color gradients

Why Bother With CSS-Only Parallax?

JavaScript parallax libraries are everywhere. ScrollMagic, rellax, lax.js — they all work by listening to the scroll event, calculating offsets, and pushing inline transforms on every frame. Honestly, that approach is fine when you're already shipping a heavy SPA, but for a landing page or a marketing hero section it's overkill. You're adding 10–30 kB of JavaScript and a scroll listener just to move a background layer a bit slower than the foreground.

The CSS-only technique uses perspective on a scroll container and translateZ on child layers. The browser's compositor handles the math natively. No event listeners. No layout thrashing. No JavaScript thread at all. It's been available since Chrome 12 and Firefox 10, so you're not betting on experimental APIs here.

That said, the CSS approach has real quirks — position: sticky conflicts, Safari's overflow clipping behavior, and the scale compensation you *have* to apply or your layers will look tiny. This article walks you through the full setup, the compensations, and the gotchas before you hit them in production.

The Core Mechanism: How perspective Creates Depth

The whole trick is built on one insight: when you set perspective on a container and then translateZ an element *toward the viewer* (positive Z), that element appears larger and moves faster relative to the scroll position. Push it away (negative Z) and it appears smaller and moves slower. That differential scroll speed is parallax.

The container needs three things to work correctly. First, perspective: 1px (or any small value — 1px is conventional). Second, overflow-y: auto so it actually scrolls. Third, height: 100vh to fill the viewport. Without the explicit height, the scroll container has nothing to scroll *within*.

.parallax-container {
  height: 100vh;
  overflow-y: auto;
  overflow-x: hidden;
  perspective: 1px;
  /* Safari needs this to avoid clipping child layers */
  -webkit-overflow-scrolling: touch;
}

Each layer inside gets transform-style: preserve-3d if it wraps nested 3D children, or just a translateZ value directly. A layer at translateZ(0) scrolls at normal speed. One at translateZ(-2px) scrolls at roughly half speed. The exact ratio depends on your perspective value — with perspective: 1px, a layer at translateZ(-1px) scrolls at 50% of the scroll rate.

Worth noting: transform-style: preserve-3d on the *container* is tempting but usually wrong. Set it on intermediate wrapper divs only when you're nesting 3D transforms. On the outermost scroll container it tends to break the scroll snapping and overflow clipping you depend on.

Scale Compensation — The Part Everyone Forgets

Here's the problem: when you translateZ(-2px) a background layer to make it scroll slower, it also appears physically smaller in the viewport. A full-bleed hero image suddenly has gaps on all four sides. You need to scale it back up to compensate, and the formula is straightforward.

The scale factor is (perspective - translateZ) / perspective. With perspective: 1px and translateZ(-2px), that's (1 - (-2)) / 1 = 3. So you apply scale(3) to cancel out the shrinkage.

.parallax-layer {
  position: absolute;
  inset: 0;
  /* Slow layer: 3x farther back, moves at 1/3 speed */
  transform: translateZ(-2px) scale(3);
}

.parallax-foreground {
  position: relative;
  transform: translateZ(0);
}

In practice, you'll want a utility or custom property for this so you don't do the arithmetic in your head every time. A small CSS custom-property approach keeps things readable:

:root {
  --perspective: 1px;
}

.layer-slow {
  --tz: -2;
  transform:
    translateZ(calc(var(--tz) * var(--perspective)))
    scale(calc((1 - var(--tz)) / 1));
}

.layer-mid {
  --tz: -0.5;
  transform:
    translateZ(calc(var(--tz) * var(--perspective)))
    scale(calc((1 - var(--tz)) / 1));
}

A Working Multi-Layer Parallax Hero

Let's put it together into something you'd actually ship. Three layers: a background image that drifts slowly, a mid-ground color wash, and a foreground with text that scrolls at normal speed. This is the structure that works reliably in Chromium, Firefox, and Safari 17+.

<div class="parallax-container">
  <section class="parallax-section">
    <!-- Background: slowest scroll -->
    <div class="parallax-layer layer-bg">
      <img src="/hero-bg.jpg" alt="" aria-hidden="true" />
    </div>

    <!-- Mid-ground: medium scroll -->
    <div class="parallax-layer layer-mid"></div>

    <!-- Foreground: normal speed -->
    <div class="parallax-layer layer-fg">
      <h1>Your Headline Here</h1>
      <p>Subheadline copy goes here.</p>
    </div>
  </section>

  <!-- Rest of page content -->
  <section class="page-content">
    <p>Normal page content below the hero.</p>
  </section>
</div>
.parallax-container {
  height: 100vh;
  overflow-y: auto;
  overflow-x: hidden;
  perspective: 1px;
}

.parallax-section {
  position: relative;
  height: 100vh;
  transform-style: preserve-3d;
}

.parallax-layer {
  position: absolute;
  inset: 0;
}

.layer-bg {
  transform: translateZ(-2px) scale(3);
  z-index: 1;
}
.layer-bg img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.layer-mid {
  background: linear-gradient(
    to bottom,
    rgba(80, 0, 200, 0.25),
    transparent
  );
  transform: translateZ(-0.5px) scale(1.5);
  z-index: 2;
}

.layer-fg {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  transform: translateZ(0);
  z-index: 3;
  color: #fff;
}

.page-content {
  position: relative;
  z-index: 10;
  background: #fff;
  padding: 4rem 2rem;
}

The page-content section needs position: relative and a solid background. If it's transparent, the parallax layers bleed through it during scroll — a very confusing visual glitch that sends people straight to Stack Overflow.

One more thing — the hero section's transform-style: preserve-3d is load-bearing. Remove it and the translateZ values on the child layers collapse to the same plane. Everything scrolls at the same speed and you've got a regular section with zero parallax effect. Always keep it on the direct parent of your layers.

Browser Quirks and Real-World Fixes

Safari is the main pain point. Before Safari 17, overflow: auto on the perspective container would sometimes clip absolutely-positioned children with large scale() values, cutting off the edges of your background layer. Adding transform: translateZ(0) to the scroll container itself (creating a new stacking context) fixed it in most cases. In 2026 you shouldn't hit this on current iOS, but if you're targeting iOS 15 devices, keep the fix around.

Firefox handles this correctly but has one wrinkle: if you set overflow-x: hidden on the parallax container *and* the body, you may suppress the parallax effect entirely on certain Linux builds. Use overflow-x: clip on the body instead — it prevents horizontal scrollbars without creating a new scroll container that fights the perspective one.

Look, the position: sticky incompatibility is the one that will actually bite you in a real project. Sticky elements inside a perspective scroll container don't behave as expected in most browsers — the stickiness anchors to the 3D context, not the viewport. If you need a sticky nav *and* parallax backgrounds, put the nav outside the parallax container entirely and use position: fixed instead.

/* Works: fixed nav outside the parallax context */
.site-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
}

/* Avoid: sticky inside parallax-container -- broken in most browsers */
.parallax-container .site-nav {
  position: sticky; /* don't do this */
}

Quick aside: will-change: transform on your parallax layers can help compositor performance on lower-end mobile devices. Don't carpet-bomb every layer with it — apply it only to the background layer that's doing the heaviest visual lifting. And strip it after the user has scrolled past the section if you can, to release the GPU memory.

Accessibility: Respecting prefers-reduced-motion

Parallax scrolling is one of the most commonly cited triggers for vestibular disorders. The official WCAG 2.2 guidance doesn't ban it outright, but it's covered under Success Criterion 2.3.3 (Animation from Interactions) at the AAA level. In practice, the ethical bar is lower than AAA — if your parallax makes someone nauseous, you've shipped a broken product for that user.

The fix is three lines of CSS. When prefers-reduced-motion: reduce is set, flatten all layers back to translateZ(0) and remove the perspective context:

@media (prefers-reduced-motion: reduce) {
  .parallax-container {
    perspective: none;
  }

  .parallax-layer {
    transform: none !important;
  }
}

That !important is warranted here — you're explicitly overriding a motion effect for accessibility reasons, and the specificity battle isn't worth losing. In 2024 Apple data showed roughly 19% of iOS users have this setting enabled, either explicitly or via accessibility profiles. That's not a niche edge case you can ignore.

If you're building hero sections with Empire UI's glassmorphism components or any of the style hubs like cyberpunk or aurora, the reduced-motion guard should be part of your base stylesheet before you layer on any visual effect. Motion-safe first, always.

When CSS Parallax Isn't the Right Tool

The CSS perspective approach works beautifully for vertical scroll parallax on a single-axis hero. It stops working well the moment you need scroll-linked animations, horizontal parallax, element-specific parallax triggers, or anything that responds to cursor position. For those cases, JavaScript — specifically the Intersection Observer API and the Web Animations API — is the right answer, not a JavaScript parallax library.

Performance-wise, CSS parallax on mobile is genuinely good for 2–3 layers. Beyond that, you're creating multiple large composited surfaces and the GPU memory cost adds up on a $200 Android device. If you're doing 5+ layers or full-viewport video backgrounds with parallax, a IntersectionObserver approach that only animates visible sections will outperform CSS at scale.

In practice, the sweet spot for CSS parallax is exactly what it was designed for: a two- or three-layer hero section where the background drifts at 30–50% of the foreground scroll speed. It's zero-dependency, it composites correctly, and it degrades gracefully to a static layout on anything that doesn't support perspective. For anything fancier, reach for Empire UI's component templates that handle the JS-driven animation patterns with all the performance work already done.

For your CSS visual design toolkit generally, the gradient generator and box shadow generator pair naturally with parallax hero design — you're usually building a layered gradient backdrop and need precise shadow values for foreground cards floating over it.

FAQ

Does CSS parallax work on mobile browsers?

Yes, but with caveats. iOS Safari and Chrome on Android both support perspective-based parallax. The main issue is Safari's momentum scrolling — add -webkit-overflow-scrolling: touch to your container. Limit yourself to 2–3 layers on mobile to avoid GPU memory pressure.

Why does my background image have white edges during parallax scroll?

You forgot the scale compensation. When you translateZ a layer backward, it shrinks visually. Apply scale((perspective - translateZ) / perspective) to bring it back to full size — with perspective: 1px and translateZ(-2px), that's scale(3).

Can I use position: sticky inside a parallax container?

Not reliably. Sticky positioning doesn't play well inside a perspective scroll context — the sticky anchor point shifts with the 3D context rather than the viewport. Move sticky elements outside the parallax container and use position: fixed instead.

Is this better than a JavaScript parallax library for performance?

For simple 2–3 layer setups, yes. CSS perspective parallax is compositor-driven with zero JavaScript overhead and no scroll event listeners. For complex, scroll-triggered animations or cursor-driven parallax, JavaScript with the Web Animations API will give you more control at comparable performance.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Flip Card: 3D Rotate Animation With and Without JavaScript3D Flip Card in CSS: Perspective, backface-visibility, Hover Reveal3D Card Effect in React: perspective, rotateX/Y and Mouse Tracking3D Card Effect in Tailwind: [perspective] and rotate3d Utilities