CSS Parallax: 16 Examples and the 4 Methods Behind Them

For years, getting a background to trail behind the text meant a JavaScript scroll listener and a transform rewritten on every frame. CSS can now do that job by itself, and the browser handles the motion. That's CSS parallax: page layers moving at different speeds while you scroll, with no scroll listener anywhere.

Designers mostly use it on hero sections and layered landing pages, where a slower background reads as distance. Fixed backgrounds and sticky layers are the simple ways to get there, and perspective with translateZ is the one that gives real depth. The newest method, scroll-driven animations, ties a keyframe animation to scroll position instead of time. Chrome for Developers documents it in Chrome 115, WebKit lists it in Safari 26.0, and Firefox turns it on by default in version 159.

Below are 16 CSS parallax examples, grouped by the four methods the guide compares: fixed backgrounds, perspective with translateZ, scroll-driven animations and sticky layers, plus one pointer-driven scene. Each one runs only when the visitor has not asked for reduced motion, and each comes with its HTML and CSS.

By Bogdan Sandu · Updated October 28, 2026

Fixed background parallax

One declaration, background-attachment: fixed. Scroll the page past these. The fixed attachment is applied only under (pointer: fine) and no-preference for motion, so phones and reduced-motion visitors get an ordinary scrolling background instead of the iOS Safari bug.

Fixed background hero

  • background-attachment: fixed
  • (pointer: fine)
  • scrim behind text

Above the ridgeline

The sky stays put while the page moves past it.

A plain buffer section scrolls normally under the band, which is what makes the fixed background read as distance.

Scroll the page past it on a desktop screen.

The illustration stays pinned to the viewport while the band and the text scroll over it. The fixed value lives inside a media query that also checks for a fine pointer, so touch devices, where iOS Safari ignores fixed and crops the image anyway, fall back to a normal background.

View code
.px-fhero {
  display: grid;
  width: 100%;
  place-items: center;
  min-height: 440px;
  padding: 40px 24px;
  background: #ffd9b8 url("plx-scene-dusk.svg") center / cover no-repeat;
}
.px-fhero__text {
  max-width: 420px;
  padding: 26px 30px;
  border-radius: 18px;
  background: rgba(42, 34, 64, .78);
  color: #fff;
  text-align: center;
}
.px-fhero__text h4 {
  margin: 0 0 6px;
  font-size: 28px;
  letter-spacing: -.02em;
}
.px-fhero__text p {
  margin: 0;
  color: #e9e3f5;
}
.px-buffer {
  padding: 30px 32px;
  background: #fff;
  color: #3b4256;
  font-size: 15.5px;
  line-height: 1.7;
}
.px-buffer p {
  max-width: 560px;
  margin: 0;
}
@media (prefers-reduced-motion: no-preference) and (pointer: fine) {
  .px-fhero {
    background-attachment: fixed;
  }
}

Alternating fixed bands

  • two fixed backgrounds
  • buffer sections
  • min-height

Night shift

Buffer sections give the eye a still reference point between bands, so the movement reads as depth instead of noise.

Low tide

Scroll the page past it on a desktop screen.

The banded layout: picture, plain section, picture. Each band reveals a different slice of its own pinned scene, and min-height decides how much of it shows. Because the images are SVG, they stay sharp when cover scales them against the whole viewport.

View code
.px-band {
  display: grid;
  width: 100%;
  place-items: center;
  min-height: 300px;
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
}
.px-band h4 {
  margin: 0;
  padding: 10px 22px;
  border-radius: 999px;
  font-size: 26px;
  letter-spacing: -.01em;
}
.px-band--night {
  background-color: #101c36;
  background-image: url("plx-scene-night.svg");
}
.px-band--night h4 {
  background: #f4ecd8;
  color: #101c36;
}
.px-band--sea {
  background-color: #d3ece6;
  background-image: url("plx-scene-sea.svg");
}
.px-band--sea h4 {
  background: #0f3f45;
  color: #fff;
}
.px-band-copy {
  padding: 34px 32px;
  background: #fff;
  color: #3b4256;
  font-size: 15.5px;
  line-height: 1.7;
}
.px-band-copy p {
  max-width: 560px;
  margin: 0;
}
@media (prefers-reduced-motion: no-preference) and (pointer: fine) {
  .px-band {
    background-attachment: fixed;
  }
}

Text as a window onto a fixed scene

  • background-clip: text
  • background-attachment: fixed
  • color: transparent

Wander

Scroll the page and watch the mountains through the letters.

Scroll the page past it on a desktop screen.

background-clip: text cuts the background to the letter shapes, and the fixed attachment pins that background to the viewport, so the letters act like windows sliding over a still landscape. The word stays real HTML text, so it is still selectable and readable by screen readers.

View code
.px-window {
  display: grid;
  justify-items: center;
  gap: 8px;
  padding: 30px 10px;
  text-align: center;
}
.px-window__word {
  margin: 0;
  background: #d99aa4 url("plx-scene-dusk.svg") center / cover no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  font-size: clamp(72px, 18vw, 200px);
  font-weight: 900;
  letter-spacing: -.04em;
  line-height: .95;
  text-transform: uppercase;
}
.px-window__sub {
  margin: 0;
  color: #9aa1b5;
  font-size: 15px;
}
@media (prefers-reduced-motion: no-preference) and (pointer: fine) {
  .px-window__word {
    background-attachment: fixed;
  }
}

Perspective and translateZ parallax

Scroll inside each frame. These use the 3D method: the frame is the scroll container with a perspective, layers are pushed back with translateZ and scaled back up with the formula from the guide, so distant layers scroll slower with no script at all.

Four-layer landscape

  • perspective: 300px
  • translateZ + scale()
  • transform-style: preserve-3d

Four layers, one scroll

Scroll inside the frame

Each layer moves at the inverse of its scale: the sky at a third of the page speed, the far range at 0.4x, the middle range at half speed and the near hills at two thirds.

None of it uses a scroll listener. The browser projects the layers through the perspective of the scroll container on every frame.

Scroll inside the frame.

The sky sits at translateZ(-600px) with scale(3), the far range at -450px and 2.5, the middle range at -300px and 2, and the near hills at -150px and 1.5, each scale from scale = 1 + (translateZ × -1) / perspective. The text section after the hero has a higher z-index, so it slides over the deep layers.

View code
.px-depth {
  width: 100%;
  max-width: 760px;
  height: 440px;
  overflow-x: hidden;
  overflow-y: auto;
  border-radius: 18px;
  background: #2a2240;
  overscroll-behavior: contain;
}
.px-depth__group {
  position: relative;
  height: 440px;
}
.px-depth__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
img.px-depth__layer {
  height: 75%;
  top: auto;
  object-fit: cover;
  object-position: bottom;
}
.px-depth__layer--sky {
  background: #ffd9b8;
}
.px-depth__layer--sky::after {
  content: "";
  position: absolute;
  top: 18%;
  right: 22%;
  width: 90px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #ff8a5b;
}
.px-depth__title {
  padding-top: 64px;
  color: #2a2240;
  text-align: center;
}
.px-depth__title h4 {
  margin: 0 0 6px;
  font-size: 30px;
  letter-spacing: -.02em;
}
.px-depth__title p {
  margin: 0;
  font-weight: 600;
}
.px-depth__copy {
  z-index: 2;
  height: auto;
  padding: 34px 32px 60px;
  background: #2a2240;
  color: #e9e3f5;
  font-size: 15.5px;
  line-height: 1.7;
}
.px-depth__copy p {
  margin: 0 0 14px;
}
@media (prefers-reduced-motion: no-preference) {
  .px-depth {
    perspective: 300px;
  }
  .px-depth__group {
    transform-style: preserve-3d;
  }
  .px-depth__layer--sky {
    transform: translateZ(-600px) scale(3);
  }
  .px-depth__layer--far {
    transform: translateZ(-450px) scale(2.5);
  }
  .px-depth__layer--mid {
    transform: translateZ(-300px) scale(2);
  }
  .px-depth__layer--near {
    transform: translateZ(-150px) scale(1.5);
  }
}

Cards floating at four depths

  • positive and negative translateZ
  • scale correction
  • speed labels
0.33xtranslateZ(-600px) scale(3)
0.5xtranslateZ(-300px) scale(2)
1xtranslateZ(0) scale(1)
1.43xtranslateZ(90px) scale(0.7)
1xtranslateZ(0) scale(1)
0.5xtranslateZ(-300px) scale(2)

Scroll inside the frame.

The table from the guide, made visible. Each card is labeled with its translateZ, its scale and the speed that follows. The 90px card sits in front of the page and scrolls faster than it, at 1.43x, while the -600px card drifts at a third of the speed.

View code
.px-float {
  --k: 1;
  width: 100%;
  max-width: 760px;
  height: 440px;
  overflow-x: hidden;
  overflow-y: auto;
  border-radius: 18px;
  background: #eef1f8;
  overscroll-behavior: contain;
}
.px-float__group {
  position: relative;
  height: 1000px;
}
.px-float__card {
  position: absolute;
  top: var(--top);
  left: calc(50% - 110px + var(--x) * var(--k));
  display: grid;
  gap: 4px;
  width: 220px;
  padding: 18px 20px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(8, 12, 20, .12);
}
.px-float__card strong {
  color: #080c14;
  font-size: 28px;
  letter-spacing: -.02em;
}
.px-float__card span {
  color: #5b6478;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}
.px-float__card--z600 {
  background: #2a2240;
}
.px-float__card--z600 strong,
.px-float__card--z600 span {
  color: #e9e3f5;
}
.px-float__card--z300 {
  background: #ffd84d;
}
.px-float__card--z90 {
  z-index: 2;
  background: #1847f0;
}
@media (max-width: 560px) {
  .px-float {
    --k: .25;
  }
}
.px-float__card--z90 strong,
.px-float__card--z90 span {
  color: #fff;
}
@media (prefers-reduced-motion: no-preference) {
  .px-float {
    perspective: 300px;
  }
  .px-float__group {
    transform-style: preserve-3d;
  }
  .px-float__card--z600 {
    transform: translateZ(-600px) scale(3);
  }
  .px-float__card--z300 {
    transform: translateZ(-300px) scale(2);
  }
  .px-float__card--z90 {
    transform: translateZ(90px) scale(.7);
  }
}

Photo hero with perspective: 1px

  • perspective: 1px
  • translateZ(-1px) scale(2)
  • z-index between groups
Hiker with a backpack looking over hazy mountain ridges at sunrise

Half-speed hero

The photo trails the text by 50%.

A layer at translateZ(-1px) under perspective: 1px renders at half its size, and scale(2) brings it back. The speed comes out as the inverse of the scale.

Keep overflow: hidden off the group, or the 3D scene flattens and the effect disappears.

Scroll inside the frame.

The compact form of the same method. With perspective: 1px, a layer at translateZ(-1px) needs scale(2) and scrolls at half speed, which is the classic hero-image effect. The photo layer is an img, so it keeps real alt text, which a CSS background cannot have.

View code
.px-hero3d {
  width: 100%;
  max-width: 760px;
  height: 440px;
  overflow-x: hidden;
  overflow-y: auto;
  border-radius: 18px;
  background: #0b0f1a;
  overscroll-behavior: contain;
}
.px-hero3d__group {
  position: relative;
  display: grid;
  place-items: center;
  height: 400px;
}
.px-hero3d__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.px-hero3d__text {
  position: relative;
  padding: 22px 28px;
  border-radius: 16px;
  background: rgba(8, 12, 20, .72);
  color: #fff;
  text-align: center;
}
.px-hero3d__text h4 {
  margin: 0 0 4px;
  font-size: 28px;
}
.px-hero3d__text p {
  margin: 0;
  color: #d5dbe8;
}
.px-hero3d__copy {
  position: relative;
  z-index: 2;
  padding: 32px 32px 70px;
  background: #fff;
  color: #3b4256;
  font-size: 15.5px;
  line-height: 1.7;
}
.px-hero3d__copy p {
  margin: 0 0 14px;
}
@media (prefers-reduced-motion: no-preference) {
  .px-hero3d {
    perspective: 1px;
  }
  .px-hero3d__group {
    transform-style: preserve-3d;
  }
  .px-hero3d__bg {
    transform: translateZ(-1px) scale(2);
  }
}

Scroll-driven parallax

Scroll the page. Each layer runs a transform animation on animation-timeline: view(), so its progress follows the element through the viewport. Everything sits inside @supports and the reduced-motion query, and browsers without support show the static layout.

Section photo that drifts

  • animation-timeline: view()
  • oversized image
  • overflow: clip
Granite cliffs and pine forest above a calm river
Granite valley

Scroll the page to move the layers.

The exact snippet from the guide, applied to a photo band. The image is 130% of the band height and runs translateY from -11% to 11% across the band trip through the viewport, so it trails the page slightly. The frame uses overflow: clip, which crops without creating a scroll container.

View code
.px-drift {
  position: relative;
  width: 100%;
  height: 400px;
  margin: 0;
  overflow: clip;
}
.px-drift__img {
  position: absolute;
  top: -15%;
  left: 0;
  width: 100%;
  height: 130%;
  object-fit: cover;
}
.px-drift figcaption {
  position: absolute;
  left: 24px;
  bottom: 22px;
  padding: 10px 18px;
  border-radius: 999px;
  background: rgba(8, 12, 20, .75);
  color: #fff;
  font-weight: 700;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .px-drift__img {
      animation: px-drift linear both;
      animation-timeline: view();
    }
  }
}
@keyframes px-drift {
  from { transform: translateY(-11%); }
  to { transform: translateY(11%); }
}

Layered landscape on view()

  • one timeline, four speeds
  • view()
  • translateY ranges

Ridgeline Trail

Scroll the page to move the layers.

The same four-layer scene as the perspective example, built on normal page scroll instead. Each layer gets its own keyframes: the sun travels 100px across the section trip, the far range 70px, the middle 40px and the near hills 14px, while the title moves the other way. Bigger travel reads as more distant.

View code
.px-land {
  position: relative;
  width: 100%;
  height: 460px;
  overflow: clip;
  background: #ffd9b8;
}
.px-land__sun {
  position: absolute;
  top: 40px;
  right: 8%;
  width: 84px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #ff8a5b;
}
.px-land__layer {
  position: absolute;
  left: 0;
  bottom: -60px;
  width: 100%;
  height: 80%;
  object-fit: cover;
  object-position: bottom;
}
.px-land__title {
  position: absolute;
  top: 34%;
  left: 0;
  right: 0;
  margin: 0;
  color: #2a2240;
  font-size: clamp(30px, 6vw, 56px);
  letter-spacing: -.03em;
  text-align: center;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .px-land__sun,
    .px-land__layer,
    .px-land__title {
      animation: linear both;
      animation-timeline: view();
    }
    .px-land__sun { animation-name: px-sun; }
    .px-land__layer--far { animation-name: px-far; }
    .px-land__layer--mid { animation-name: px-mid; }
    .px-land__layer--near { animation-name: px-near; }
    .px-land__title { animation-name: px-title; }
  }
}
@keyframes px-sun {
  from { transform: translateY(-50px); }
  to { transform: translateY(50px); }
}
@keyframes px-far {
  from { transform: translateY(-35px); }
  to { transform: translateY(35px); }
}
@keyframes px-mid {
  from { transform: translateY(-20px); }
  to { transform: translateY(20px); }
}
@keyframes px-near {
  from { transform: translateY(-7px); }
  to { transform: translateY(7px); }
}
@keyframes px-title {
  from { transform: translateY(40px); }
  to { transform: translateY(-40px); }
}

Horizontal cards with view(inline)

  • view(inline)
  • scroll-snap
  • translateX
  • Canal houses
  • Coast road
  • Snow camp
  • Highland road
  • Brick alley
  • City skyline

Scroll the page to move the layers.

Parallax on the inline axis, the case from the FAQ. Swipe or scroll the row sideways: each photo is wider than its card and slides the opposite way as the card crosses the scroller, so the picture lags behind its frame. view(inline) reads the horizontal scroller instead of the page.

View code
.px-hrow {
  display: flex;
  gap: 16px;
  width: 100%;
  margin: 0;
  padding: 4px 4px 16px;
  overflow-x: auto;
  list-style: none;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
}
.px-hrow li {
  position: relative;
  flex: 0 0 min(280px, 78%);
  height: 340px;
  overflow: clip;
  border-radius: 18px;
  scroll-snap-align: center;
}
.px-hrow img {
  position: absolute;
  top: 0;
  left: -20%;
  width: 140%;
  height: 100%;
  object-fit: cover;
}
.px-hrow span {
  position: absolute;
  left: 14px;
  bottom: 14px;
  padding: 8px 14px;
  border-radius: 999px;
  background: rgba(8, 12, 20, .75);
  color: #fff;
  font-size: 14px;
  font-weight: 700;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .px-hrow img {
      animation: px-hpan linear both;
      animation-timeline: view(inline);
    }
  }
}
@keyframes px-hpan {
  from { transform: translateX(-14%); }
  to { transform: translateX(14%); }
}

Headlines at three speeds

  • translateX on view()
  • opposite directions
  • overflow: clip

Layers that

move at

different speeds

Scroll the page to move the layers.

Parallax does not need pictures. Three lines of type slide sideways at different rates and in opposite directions as the section crosses the viewport. The words stay real text in normal order, so they read the same with the motion off.

View code
.px-lines {
  display: grid;
  width: 100%;
  gap: 4px;
  padding: 56px 0;
  overflow: clip;
  background: #080c14;
}
.px-lines__row {
  margin: 0;
  color: #f4f6fb;
  font-size: clamp(44px, 10vw, 104px);
  font-weight: 900;
  letter-spacing: -.04em;
  line-height: 1;
  white-space: nowrap;
}
.px-lines__row--a {
  padding-left: 6%;
}
.px-lines__row--b {
  padding-left: 30%;
  color: #ff6b3d;
}
.px-lines__row--c {
  padding-left: 10%;
  color: #7ea0ff;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .px-lines__row {
      animation: linear both;
      animation-timeline: view();
    }
    .px-lines__row--a { animation-name: px-line-a; }
    .px-lines__row--b { animation-name: px-line-b; }
    .px-lines__row--c { animation-name: px-line-c; }
  }
}
@keyframes px-line-a {
  from { transform: translateX(12%); }
  to { transform: translateX(-12%); }
}
@keyframes px-line-b {
  from { transform: translateX(-22%); }
  to { transform: translateX(14%); }
}
@keyframes px-line-c {
  from { transform: translateX(5%); }
  to { transform: translateX(-5%); }
}

Hero that sinks as it leaves

  • animation-range: exit
  • opacity + transform
  • scrim
Yellow tent pitched in snow below white mountain peaks

Base camp

Scroll up past this section.

Scroll the page to move the layers.

Nothing moves until the hero starts to leave the top of the viewport. Then animation-range: exit takes over: the photo sinks at a fraction of the scroll speed while the headline rises and fades. Both animate only transform and opacity, so the effect stays on the compositor.

View code
.px-exit {
  position: relative;
  width: 100%;
  display: grid;
  place-items: center;
  height: 420px;
  overflow: clip;
  background: #0b0f1a;
}
.px-exit__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.px-exit__text {
  position: relative;
  padding: 22px 30px;
  border-radius: 16px;
  background: rgba(8, 12, 20, .7);
  color: #fff;
  text-align: center;
}
.px-exit__text h4 {
  margin: 0 0 4px;
  font-size: 32px;
  letter-spacing: -.02em;
}
.px-exit__text p {
  margin: 0;
  color: #d5dbe8;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .px-exit__img,
    .px-exit__text {
      animation: linear both;
      animation-timeline: view();
      animation-range: exit 0% exit 100%;
    }
    .px-exit__img { animation-name: px-sink; }
    .px-exit__text { animation-name: px-rise; }
  }
}
@keyframes px-sink {
  to { transform: translateY(45%); }
}
@keyframes px-rise {
  to {
    opacity: 0;
    transform: translateY(-70px);
  }
}

Sticky and pointer parallax

Layers that hold still while content moves past them, plus one effect driven by the pointer instead of the scrollbar. The sticky examples scroll inside their own frames, because the scroll container is what a sticky element pins to.

Sticky photo under scrolling cards

  • position: sticky
  • negative margin overlap
  • scroll() zoom
Narrow road winding below green highland cliffs

Day one

The road climbs out of the valley before the first pass.

Day two

Long stretches with nothing but sheep and stone walls.

Day three

The last descent to the coast and the ferry.

Scroll inside the frame.

The photo sticks to the top of the frame, a layer at zero speed, while the text cards scroll over it. Where scroll-driven animations work, the photo also eases out of a slight zoom across the scroll. Because the photo is an img, it carries alt text, the main reason to pick sticky over a background.

View code
.px-under {
  width: 100%;
  max-width: 760px;
  height: 440px;
  overflow-y: auto;
  border-radius: 18px;
  background: #0b0f1a;
  overscroll-behavior: contain;
}
.px-under__media {
  position: sticky;
  top: 0;
  height: 440px;
  overflow: clip;
}
.px-under__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.px-under__cards {
  position: relative;
  z-index: 1;
  display: grid;
  gap: 220px;
  margin-top: -440px;
  padding: 300px 24px 120px;
}
.px-under__cards article {
  max-width: 340px;
  padding: 22px 24px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 18px 40px rgba(8, 12, 20, .25);
}
.px-under__cards article:nth-child(even) {
  margin-left: auto;
}
.px-under__cards h4 {
  margin: 0 0 6px;
  color: #080c14;
  font-size: 20px;
}
.px-under__cards p {
  margin: 0;
  color: #3b4256;
  font-size: 15px;
  line-height: 1.6;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .px-under__media img {
      animation: px-zoom-out linear both;
      animation-timeline: scroll();
    }
  }
}
@keyframes px-zoom-out {
  from { transform: scale(1.18); }
  to { transform: scale(1); }
}

Stacking sticky cards

  • sticky top per card
  • calc(var(--i))
  • no script

Discover

Interviews and analytics

Define

Problems worth solving

Design

Flows, wireframes and UI

Deliver

Build, test and ship

Scroll inside the frame.

Every card is sticky, with a top offset that grows by 14px per card, so each one stops a little lower than the last and the cards pile up like a deck as you scroll. It works in every current browser, because it needs nothing beyond position: sticky.

View code
.px-stack {
  width: 100%;
  max-width: 560px;
  height: 440px;
  overflow-y: auto;
  padding: 18px 18px 120px;
  border-radius: 18px;
  background: #e8ecf5;
  overscroll-behavior: contain;
}
.px-stack article {
  position: sticky;
  top: calc(12px + var(--i) * 14px);
  display: grid;
  align-content: end;
  height: 280px;
  margin-bottom: 60px;
  padding: 24px 26px;
  border-radius: 20px;
  background: var(--c);
  box-shadow: 0 -8px 24px rgba(8, 12, 20, .14);
  color: #fff;
}
.px-stack h4 {
  margin: 0 0 4px;
  font-size: 34px;
  letter-spacing: -.03em;
}
.px-stack p {
  margin: 0;
  opacity: .9;
}

Sticky column beside a scrolling list

  • sticky panel
  • container query
  • no overflow on ancestors
  1. Pick datesCheck the season and crowds.
  2. Choose a baseOne town, short day trips.
  3. Book transportTrains first, then cars.
  4. Plan two hikesOne easy, one hard.
  5. Leave a free dayWeather will need it.

Scroll inside the frame.

The left panel holds still while the steps scroll past it, and it lets go when its column ends, which is the containment a fixed background cannot give. A container query collapses it to a sticky header strip in narrow frames. No ancestor between the panel and the scroller sets overflow, so nothing breaks the sticking.

View code
.px-split {
  width: 100%;
  max-width: 760px;
  height: 440px;
  overflow-y: auto;
  border-radius: 18px;
  background: #fff;
  container-type: inline-size;
  overscroll-behavior: contain;
}
.px-split__grid {
  display: grid;
  grid-template-columns: 1fr 1.2fr;
  align-items: start;
}
.px-split__panel {
  position: sticky;
  top: 0;
  height: 440px;
  padding: 34px 28px;
  background: #0f3f45;
  color: #fff;
}
.px-split__panel h4 {
  margin: 0 0 10px;
  font-size: 28px;
  letter-spacing: -.02em;
  line-height: 1.15;
}
.px-split__panel p {
  margin: 0;
  color: #bfe3dd;
}
.px-split__list {
  display: grid;
  gap: 14px;
  margin: 0;
  padding: 28px 24px 200px;
  list-style: none;
}
.px-split__list li {
  display: grid;
  gap: 4px;
  min-height: 150px;
  align-content: center;
  padding: 0 22px;
  border-radius: 16px;
  background: #eef6f4;
}
.px-split__list strong {
  color: #0f3f45;
  font-size: 19px;
}
.px-split__list span {
  color: #3b4256;
  font-size: 14.5px;
}
@container (max-width: 520px) {
  .px-split__grid {
    grid-template-columns: 1fr;
  }
  .px-split__panel {
    z-index: 1;
    height: auto;
    padding: 18px 22px;
  }
  .px-split__panel h4 {
    font-size: 20px;
  }
  .px-split__list {
    padding-top: 18px;
  }
}

Pointer-driven layers

  • custom properties from pointermove
  • calc() per depth
  • transition smoothing

Move your pointer

Move your pointer over the scene.

Move the pointer over the scene. A few lines of script write the pointer position into --px and --py as values from -1 to 1, and each layer multiplies them by its own depth, so near layers shift more than far ones. The CSS that reads the variables is opt-in under no-preference, so reduced-motion visitors get a still scene.

View code
.px-pointer {
  --px: 0;
  --py: 0;
  position: relative;
  width: 100%;
  max-width: 760px;
  aspect-ratio: 16 / 9;
  overflow: clip;
  border-radius: 18px;
  background: #ffd9b8;
}
.px-pointer__sun {
  position: absolute;
  top: 16%;
  right: 20%;
  width: 13%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #ff8a5b;
}
.px-pointer__layer {
  position: absolute;
  left: -8%;
  bottom: -8%;
  width: 116%;
  height: 80%;
  object-fit: cover;
  object-position: bottom;
}
.px-pointer__label {
  position: absolute;
  top: 18px;
  left: 20px;
  margin: 0;
  padding: 8px 14px;
  border-radius: 999px;
  background: rgba(42, 34, 64, .8);
  color: #fff;
  font-size: 14px;
  font-weight: 700;
}
@media (prefers-reduced-motion: no-preference) {
  .px-pointer__sun,
  .px-pointer__layer {
    transition: transform .3s ease-out;
  }
  .px-pointer__sun {
    transform: translate(calc(var(--px) * -6px), calc(var(--py) * -4px));
  }
  .px-pointer__layer--far {
    transform: translate(calc(var(--px) * -12px), calc(var(--py) * -6px));
  }
  .px-pointer__layer--mid {
    transform: translate(calc(var(--px) * -24px), calc(var(--py) * -10px));
  }
  .px-pointer__layer--near {
    transform: translate(calc(var(--px) * -40px), calc(var(--py) * -16px));
  }
}

What Is CSS Parallax and Which Methods Create It?

Depth comes from the speed gap. A background layer travels slower than the text in front of it, and the eye reads that gap as distance, the same illusion designers know as parallax scrolling.

The older route ran on scripts. A JavaScript scroll listener read the position and rewrote transform values on every layer. CSS methods hand that work to the browser’s own scrolling and compositing.

MethodMechanismMain failure mode
background-attachment: fixedBackground pinned to the viewportPartial support on iOS Safari
Perspective with translateZ3D depth inside a scroll containeroverflow: hidden on a group flattens the 3D scene
Scroll-driven animationsKeyframes tied to scroll positionMissing in Firefox before version 159
Sticky with transformLayer pinned inside a tall wrapperAncestor overflow stops the sticking

None of the four covers every case. Fixed backgrounds take the least code. Perspective is the one with real layered depth. Scroll-driven animations have the cleanest markup, and sticky is handy when the effect has to stay inside its own section.

How to Build Parallax with background-attachment: fixed

Add background-attachment: fixed to an element with a background image, and the image stays pinned to the viewport while content scrolls over it. One declaration produces the effect.

The usual setup is a hero image behind a headline, with normal content scrolling past it.

.parallax {
  background-image: url("hero.jpg");
  background-attachment: fixed;
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
  min-height: 500px;
}

Alternating parallax bands with plain “buffer” sections is a design choice, not a requirement (LogRocket describes this banded layout). min-height sets how much of the picture is visible per band. And because cover sizes the image against the viewport when the attachment is fixed, the source file needs to be at least screen-sized.

Can I use lists iOS Safari as partial support in every version from 5.0 through 27.2, with a note that fixed is recognized but has no effect.

WebKit’s bug tracker (bug 234779) spells out what partial means. The background scrolls with the page but is still cropped as if it were fixed. The same report notes that @supports claims the property is supported, so a feature query cannot be relied on to gate it.

Turn the effect off with media queries and fall back to background-attachment: scroll. A width breakpoint misses large tablets, so (pointer: coarse) targets touch screens more directly.

The upside is how little it takes. One declaration, no transforms, and it works on any element that has a background, with no scroll container to restructure.

The downsides are real, though. It’s broken on iOS Safari. You also get one layer only, always at zero speed relative to the viewport, and background images carry no alt text.

How Does the Perspective and translateZ Method Work?

Keith Clark described this technique in his August 2014 article on pure CSS parallax websites. Layers render at different depths inside one 3D scene, so scrolling moves distant layers slower than near ones. It needs a scroll container with a perspective value and children offset on the Z axis, and the pushed-back layers get a scale() correction so they keep their original size.

The W3C CSS Transforms Module Level 2 defines the properties involved, which are perspective, perspective-origin, transform-style and translateZ.

Setting Up the Scroll Container

The scroll container, not the page, supplies the 3D viewport. Per Clark, giving an element a height plus the CSS perspective property creates a fixed 3D viewport centered on it.

  1. Create the container with height: 100vh, overflow-x: hidden, overflow-y: auto and perspective: 1px.
  2. Add a group wrapper per section with position: relative and transform-style: preserve-3d.
  3. Place each layer inside its group with position: absolute and all four offsets at 0.
  4. Give the base layer translateZ(0) so it scrolls at normal speed.
  5. Give the background layer a negative translateZ, such as -1px, plus the matching scale value.
  6. Keep the text on the base layer.

Calculating the Scale Correction

Pushing a layer back shrinks it, so it needs scale() to render at its original size. Clark’s formula is scale = 1 + (translateZ × -1) / perspective.

translateZ (perspective 300px)ScaleScroll speed vs page
90px0.71.43x (faster)
011x
-300px20.5x
-600px30.33x

The translateZ values come from Clark’s demo. The speed column follows from the formula, since a layer’s scroll speed is the inverse of its scale.

perspective-origin moves the vanishing point. Clark’s article shows perspective-origin-x: 100% to shift it to the right edge.

Pros and Cons of the Perspective Method

You get real multi-layer depth with no scripts, and each layer’s speed comes down to one number.

The catch is that the page scrolls inside a div, so window scroll position stays at 0. overflow: hidden on a group breaks the effect, per Clark. Scaled layers also grow, so background images need oversized source files.

How to Create Parallax with Scroll-Driven Animations

Scroll-driven animations link a keyframe animation to scroll progress through animation-timeline, so the animation advances with the scrollbar instead of the clock. For parallax, that means a transform animation on a background layer.

@keyframes drift {
  from { transform: translateY(-15%); }
  to   { transform: translateY(15%); }
}

.parallax-bg {
  animation: drift linear both;
  animation-timeline: view();
}

The animation is an ordinary set of CSS keyframes. Linear easing keeps the motion proportional to scroll distance.

scroll() gives you a scroll progress timeline, which tracks how far a scroll container has scrolled. MDN lists nearest, root and self as scroller values. view() is a view progress timeline that tracks an element’s passage through the scrollport, and it suits section-level parallax because each section animates on its own schedule.

animation-range limits where on the timeline the animation runs, using named ranges such as cover, contain, entry and exit. Declare animation-timeline and animation-range after the animation shorthand, because the shorthand resets the longhands it leaves out to their initial values (animation-timeline returns to auto, per MDN).

Named timelines come from scroll-timeline-name or view-timeline-name. By default, a named timeline can only be used by descendants of the element that declares it (Chrome for Developers), and timeline-scope extends that reach.

Wrap the rules in @supports (animation-timeline: scroll()). Browsers without support keep the static layer, which is a fine failure for decoration.

A polyfill exists, but it moves the work back to JavaScript and removes the main reason to choose this method.

Chrome 115 added the CSS properties and the ScrollTimeline and ViewTimeline JavaScript classes (Chrome for Developers guide), and Safari 26.0 adds scroll-driven animations (WebKit release notes). There are 2 timeline types, scroll progress and view progress. The guide names scroll-timeline-name, view-timeline-name, animation-timeline and animation-range as the properties involved. Firefox 159, scheduled for October 27, 2026, enables scroll-driven animations by default (MDN’s Firefox 159 release notes), which puts the feature in all three engines. MDN had not yet marked scroll() or animation-range as Baseline at the time of writing.

The markup stays clean, it runs on normal window scroll, and per-layer speed comes from the keyframes. What you give up is browser reach, so a static fallback is needed, and named timelines come with scope rules.

How Do Position Sticky and Transform Create Layered Scroll Effects?

A sticky layer inside a tall wrapper stays put while content scrolls over it, which is parallax at zero speed. Add a transform driven by a scroll-driven animation and the layer drifts at a fraction of page speed.

That dependency matters. Where scroll-driven animations are unsupported, the layer stays pinned and never drifts.

The gain over a fixed background is containment. A sticky layer stops at the bottom edge of its containing block, so the effect ends with its wrapper.

The usual way this breaks is ancestor overflow. MDN positions a sticky element against its nearest scrolling ancestor, so overflow: hidden, auto or scroll on any ancestor makes it the reference, and the layer stops sticking. overflow: clip fixes that, because it clips content without creating a scroll container, as Ben Frain’s write-up of MDN’s definition points out.

Size is the other snag. A sticky element larger than its scroll container cannot hold its position, and once you scroll far enough, the top portion that had stuck scrolls away (Frontend Masters).

Pick sticky when the layer should be a real element, such as an SVG or an img with alt text, rather than a CSS background.

Which CSS Parallax Method Should You Choose?

Perspective suits layered depth, and scroll-driven animations suit a single drifting layer on current browsers. A JavaScript library only makes sense when CSS can’t express the motion, and most pages never reach that stage.

For one background image and a desktop audience, background-attachment: fixed is enough. Three or more layers with real depth point to perspective with translateZ. One or two drifting layers on current browsers fit scroll-driven animations behind @supports, and sticky positioning is the pick for a layer that must end with its section.

Skip the fixed background whenever phone traffic matters. iOS Safari gives it no effect in any version, even though it is the shortest of the four to write.

A library earns its weight when the effect needs scrubbed timelines, pinned sections, or sequencing across many elements. The other trigger is browser reach. Scroll-driven animations still carry a cross-browser compatibility gap, and a script closes it at the cost of weight.

LibraryWhat it doesNote
GSAP ScrollTriggerScrubbed, pinned, sequenced scroll animationFree, commercial use included (current README)
Rellax.jsPer-element parallax speed through a data attributeRoughly 2.5 KB gzipped, v1.12.1 (Bundlephobia)
JarallaxParallax for background images and video (YouTube, Vimeo, self-hosted)disableParallax option for mobile
Locomotive ScrollSmooth scrolling, in-view detection, data-scroll-speed parallaxVersion 5 is built on Lenis
LenisSmooth scrolling layerOften paired with ScrollTrigger

Licensing advice conflicts between sources. Older GreenSock forum answers say sites that charge multiple customers need a Business Green membership, while the current GSAP README says the whole toolset, plugins included, is free for commercial use since Webflow’s acquisition.

Read the license text for the exact version before shipping a paid product.

CSS alone is enough when the effect is decoration, not choreography, and a static fallback is acceptable on older browsers. It also wins when script weight counts against a performance budget.

A library makes sense when scroll position must drive timelines, pins or sequences, or when browsers below the current generation are a hard requirement. Tuning speed per element at runtime is another reason.

The price of a library is script weight plus main-thread work on every scroll frame. Build the static mobile layout first, as in mobile-first design, and add either approach on top.

Does CSS Parallax Hurt Page Performance?

Not when the motion stays on transform. Transform-based CSS parallax stays cheap, and the cost rises with layer count and with any property that forces paint or layout.

Compositor-Friendly Properties

web.dev’s rendering guidance names 2 properties that the compositor can animate without layout or paint, transform and opacity.

translateY, translateZ and scale all count as transform, so the perspective and scroll-driven methods stay inside that budget. Animating background-position, top or margin does not.

Can I use adds a note on the fixed-background method. Most mobile devices show a delay in updating the background position after scrolling.

Layer and Memory Cost

MDN warns that excessive will-change use causes excessive memory use, and advises against adding it to pages that already perform well.

Each compositor layer takes memory too, and on low-memory devices the cost can outweigh the gain (web.dev). For compositing time during scrolling, web.dev suggests aiming for roughly 4-5ms.

Each translateZ layer in the perspective method is typically promoted to its own compositor layer. Count layers, not sections.

Layout Shift and Core Web Vitals

When a parallax section has no reserved height, the image loads, the section grows, and everything below it jumps. That’s CLS, and web.dev sets the good threshold at 0.1.

Set min-height or aspect-ratio on every parallax container and declare width and height on the layer images. The hero image should load eagerly, since it is usually the LCP element.

Measuring the Cost

Open the Layers view in Chrome DevTools to count layers and see why each one exists. Then record a scroll in the Performance panel to catch layout shifts, and run Lighthouse for a lab CLS reading.

How to Make CSS Parallax Accessible

Run the motion only under prefers-reduced-motion: no-preference, serve a static layout to everyone else, and treat WCAG SC 2.3.3 as the standard to meet. The fallback is part of the build, not a patch.

Motion triggers real symptoms. Vestibular reactions can include dizziness and nausea, and web.dev also lists migraine headaches, some serious enough to need bed rest.

Motion is one barrier among many in web accessibility work, but it is the one parallax creates by design.

The W3C’s Understanding document for SC 2.3.3 names parallax scrolling as often non-essential animation. The criterion sits at Level AAA, arrived with WCAG 2.1, and carries into WCAG 2.2.

SC 2.2.2 (Pause, Stop, Hide) is the other motion criterion. It covers animation the page starts on its own that runs longer than five seconds, so scroll-driven parallax falls under 2.3.3 instead.

  1. Write the base CSS with no motion, so the static layout is the default.
  2. Put the parallax rules inside @media (prefers-reduced-motion: no-preference), so motion is opt-in.
  3. For scroll-driven animations, nest the @supports test inside that same media query.
  4. For the perspective method, set perspective: none on the container and transform: none on the layers under (prefers-reduced-motion: reduce).
  5. For libraries, check window.matchMedia(“(prefers-reduced-motion: reduce)”) and skip initialization, or call destroy() where one exists (Rellax resets elements to their original positions).
  6. Test the reduced setting in DevTools and on a real device.
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .parallax-bg {
      animation: drift linear both;
      animation-timeline: view();
    }
  }
}

Visitors turn the preference on in their OS settings, and the path differs by system.

  • macOS: System Settings, Accessibility, Display, Reduce motion
  • iOS and iPadOS: Settings, Accessibility, Motion, Reduce Motion
  • Windows 11: Settings, Accessibility, Visual effects, Animation effects
  • Windows 10: Settings, Ease of Access, Display, Show animations in Windows
  • Android: Accessibility, Remove animations

In Chrome DevTools, the command menu runs Emulate CSS prefers-reduced-motion: reduce, which tests the CSS layer without touching OS settings.

A static layout is the safest fallback. Craft CMS’s design guidance offers a fade-in as a polished alternative.

When Does CSS Parallax Break or Not Apply?

Drop it on pages built for reading or form-filling, and anywhere layout height is unstable. In those cases the effect adds cost and takes something away.

Long articles, documentation and legal text gain nothing from drifting backgrounds, so use a static hero there. On forms and checkout, motion behind inputs competes with the task. A flat background works better.

Text over a moving image is a problem because contrast changes frame by frame, and a scrim behind the text helps. Full-height sections on phones overshoot the visible screen with 100vh, so use svh. Every layer costs memory on low-memory devices, which means cutting the layer count. Reduced-motion users get the static layout, no exceptions.

The mobile viewport changes as toolbars collapse, which is why web.dev calls 100vh too tall on load.

svh sizes to the small viewport (toolbars shown), lvh to the large one, and dvh follows the toolbar as it moves. dvh does not update at 60fps (web.dev), and a dvh-sized element reflows during scroll, so svh suits always-on hero sections.

A caption that clears color contrast over the sky in a photo can fail over the dark ground a few hundred pixels later. Test the worst frame, not the first one.

A scrim layer with fixed opacity behind the text removes the variable.

CSS Parallax FAQ

Can CSS parallax run horizontally?

Yes. Scroll-driven animations accept an inline axis through scroll(inline) and view(inline), so a horizontal scroller can drive translateX the way vertical scrolling drives translateY.

Rellax supports horizontal parallax too, but its README keeps it disabled by default.

How many layers are too many?

No source sets a fixed limit. Each compositor layer takes memory (web.dev), and the cost shows first on low-memory phones.

Start with 2 or 3 layers per section, then check the Layers view in Chrome DevTools and scroll on a mid-range phone.

Does parallax affect SEO?

Parallax is a presentation effect, not a ranking signal. Its side effects matter more, since layout shift and heavy hero images feed Core Web Vitals.

Text that exists only inside a background image cannot be read as text, so keep headlines in HTML.

What image size and format suit parallax layers?

Export each layer at the width it renders, not the width of its box. A layer scaled by 2 renders at twice its box, so a small source turns soft.

Compress the file as WebP or AVIF, and load the hero layer eagerly.

Does parallax work in WordPress page builders like Elementor?

Yes, with limits. Builders that rely on background-attachment: fixed inherit the iOS limitation, and Elementor’s help center says so.

Elementor recommends its Motion Effects instead, or setting the attachment back to Default on mobile.

What to Test Before Shipping CSS Parallax

Start with the reduced-motion fallback, then check a real phone. After that, look at the layer and layout-shift profile, and finish with text contrast at the worst frame.

The order follows who gets excluded. Without a fallback, visitors with vestibular disorders get hurt. A broken phone layout affects every mobile visitor, and too many layers mostly punish low-memory devices.

Contrast comes last because it is the only check that a scrim layer fixes in minutes.

Visitors on reduced-motion settings and on browsers without scroll-driven animations see a flat page, and that’s by design. The layout has to hold up without the motion.

As of October 2026, MDN does not yet mark scroll-driven animations as Baseline, although Firefox 159 enables them by default from October 27, 2026. Keep the static fallback in the build until older Firefox versions drop out of your analytics.

The same reduced-motion gating applies to every scroll-linked effect, including those in the guide to CSS animations on scroll.