DEV Community

Cover image for Pure CSS parallax perspective beyond landscape images
Ingo Steinke
Ingo Steinke

Posted on • Updated on

Pure CSS parallax perspective beyond landscape images

I researched many tutorials and examples about "parallax scrolling" effects, and I wasn't impressed. Most parallax examples were not elegant both in a visual way and concerning their code. I often struggled to understand the principle hidden between irrelevant styles and misleading class names. Some "pure CSS" examples still use JavaScript, and most don't care about accessibility, using parallax movements even if the users have set to prefer reduced motion.

This (meta-) tutorial shows some experiments and deliberate failures on my way to understand what is going on and how to code a working, robust, accessible, and maintainable perspective effect beyond the typical "awesome landscape" image.

parallax animation example gif by Wikimedia commons
Source: Wikimedia Commons: File:Parallax_scrolling_example_scene.gif.

Table of Contents

Definition(s) of parallax scrolling effects

What is a "parallax perspective effect" anyway? Simple as it may seem, there seem to be different interpretations of what it is on a website, generalized as an umbrella term for various kinds of scroll-linked movement effects. A popular parallax effect simply resizes full-width hero images to make them slightly larger than they need to be, then adding a subtle scroll-linked movement to make it appear as if we were looking at a distant landscape through a window while moving. This is quite nice, although it does not make much sense if the pictures don't show a distant landscape.

Parallax beyond awesome landscape images

Another reason for restricting the effect to hero images is that this circumvents a lot of the problems of more complex scenarios, as we will see when inspecting code demos below.

Animated website scrolling example by Andrej Sharapov on dribbble
Source: CSS mix-blend-mode and Awesome parallax scrolling by Andrej Sharapov on dribbble

Another scroll-linked effect that changes the content, like overlaying a photograph over a matching drawing or wireframe model, like in the popular Ivy Chen example, has also been listed as a parallax effect, while it's actually something completely different.

According to Wikipedia, parallax is "a displacement or difference in the apparent position of an object viewed along two different lines of sight", and parallax scrolling applies this effect in analog cartoon animation films or "in computer graphics where background images move past the camera more slowly than foreground images, creating an illusion of depth in a 2D scene of distance" like the clouds in classic jump-and-run-games like Super Mario Brothers or The Great Giana Sisters.

animated sequence of the classic 1980s computer game Giana Sisters, found on makeagif.com
Source: The Great Giana Sisters game sequence on makeagif.com.

Unlike the classic film or computer game examples, where something runs, rides, or flies in a horizontal direction in front of a landscape, websites are typically scrolled down in a vertical direction.

Misconceptions, outdated and complicated tutorials

I heard different things about perspective effects, from "just add a perspective property" to "the HTML document / body must act as a perspective parent / scrolling container, thus making it necessary to refactor our project and risk undesired side-effects on pages that don't even use the scrolling effect.

I found a relatively straightforward codepen by Keith Clark related to his 2014 Pure CSS Parallax Websites that I forked and modified in order to better understand and reduce to its core requirements to generate a minimal template for different kinds of parallax perspective effects that can be added to any project without breaking existing work and without needing to modify existing markup. I only succeeded kind of half way.

Some of the "pure CSS" parallax examples actually contain JavaScript, and some aspects might be outdated and obsolete nearly ten years later. But the essence is still the same! Quoting Keith's 2014 blog post:

The parallax class is where the parallax magic happens. Defining the height and perspective style properties of an element will lock the perspective to its centre, creating a fixed origin 3D viewport. Setting overflow-y: auto will allow the content inside the element to scroll in the usual way, but now descendant elements will be rendered relative to the fixed perspective. This is the key to creating the parallax effect.

Using this technique and replacing the background layer div with a .parallax::after style, we should be able to achieve our goals without JavaScript, global styles and side effects.

Pure CSS and minimal additional markup

Why would we even want to do that? While Tailwind and Bootstrap use functional classes everywhere, React projects and content management software introduce illegible generic class names based on random instance hashes, why not write HTML like it's 1999 and add a lot of new div elements to wrap our virtual layers? You might guess from my tone, and my previous articles, that's not how I prefer to code modern websites.

Trying to document how and why a decorative module introduced global styles and possible side effects, I could not help but feel I was implementing it in the wrong way.

Screenshot of my IDE with the code mentioned below and a README section about side effects and global styles

I prefer to introduce new design

  • in a modular / atomic way to prevent side-effects,
  • separating design and content, providing themes as optional and interchangeable skins around the bare, semantic content,
  • in a minimal way to keep my code readable and reduce loading time.

That's why I don't think that I succeeded to create a minimal, portable template for a simple parallax scrolling effect yet. But still it might be a step forward - after several steps back and forth, as you can see in this intermediate debugging screenshot, trying to understand and debug adding colorful borders and experimenting with position, height, overflow, and z-index:

Screenshot trying to debug an intermediate stage adding colorful borders and experimenting with position, height, overflow, and z-index

The new code still has side-effects, as it seems that we need to make its the perspective background layer's height greater than the content, and that clipping with overflow: hidden did somehow break the perspective effect, so we need to give adjacent unrelated elements a higher z-index and define a background color to make sure that they stay on top and remain visually unaffected. Another aspect still true and already mentioned in the "parallax sections" chapter of Keith's good old tutorial:

One important rule to keep in mind when grouping elements is, we cannot clip the content of a group. Setting overflow: hidden on a parallax__group will break the parallax effect. Unclipped content will result in descendant elements overflowing, so we need to be creative with the z-index values of the groups to ensure content is correctly revealed/hidden as the visitor scrolls through the document.

At least we could, in theory, limit our perspective effect to the parallax container so we don't need to apply global html, body styles. But won't that break continuous body scrolling and add scrollbars to the sections instead?

Side view of parallax demo layers in Keith Clark's demo #3

As we can see in the "debug mode" side view of Keith Clark's third demo, we actually have at least three perspective layers: foreground elements, matching section backgrounds (containing background graphics), and a "deep background" which might be the document body or another parent wrapper element containing the effect to make it independent from the rest of the document.

Contain the effect, ensure scrolling, and don't mess with the global document!

But how did that demo prevent multiple scrollbars and make sure that we can scroll the whole document in the usual way using a mouse or touchpad, either swiping, using the mouse wheel, or dragging the scrollbar, or pressing arrow keys? Well, it just didn't! There are two static elements fixed to the top and bottom of the page, but the deep background has been set to height: 100vh; overflow-y: auto and the document body to overflow: hidden.

I would like to move the overflow: hidden to our contained section without breaking the perspective effect. But do we really need to add another wrapper, when the parallax container already got the same height and overflow styles? Turns out the body styles are redundant, irrelevant, and obsolete, so I can delete this section when everything works.

Debugging screenshot of the same demo with disabled body styles in the developer tools

The point is that demo doesn't use any other content sections before or after the deep background that are not pinned to an absolute position. If it did, it would have the same problem as my interim codepen: double scrollbars and interrupted user experience.

Screenshot of the same demo with static elements before the deep background section resulting in double scrollbars

At this point, I still failed or refused to understand why I can't simply make the deep background wrapper match its base layer's content height and clip the overflowing decorative background layer without breaking the parallax perspective effect.

Understanding my intermediate double-scrollbars example

While the original codepen had multiple foreground elements inside the parallax (group) container, we can add another wrapper around them to make it easier to verify (and hopefully control) its height and behavior.

Screenshot of my own codepen with added foreground wrapper element marked with a blue border

Now I can move any layout styles (just some padding in my minimal example) to the new foreground content wrapper and set margin: 0; padding: 0 to the parallax (group) container.

But what happens when I change its height to match the foreground content wrapper and set decorative background, or the parent parallax (group) container, or both, to clip its overflowing content? Will that break my awesome parallax perspective effect again? It will, because we need some scrolling inside the container to make the layers move. If there is no more scrolling, there is no more scrolling effect either!

(Dis)advantages of a global body height approach

This is the reason that most of the seemingly overengineered tutorials use the whole document body as a deep background and use z-index to make unrelated content areas stay on top of the decorative background layer and have an opaque background-color, while the decorated sections have a transparent one so that the decorations are visible behind its content.

Using the document body as a deep background has the advantage that it's already there, without having to add markup outside of our parallax section / module. But we risk to interfere with other modules that might have done other things with the body, so this approach might break unrelated code, especially when used in a large project or a modular CMS environment like WordPress.

As our decorative background layer is a child of its parallax group container, its absolute positioning with a four edges set to zero offset ties its location and size where it belongs, while still allowing it to scroll more slowly due to its perspective and distance / Z-axis transformation, resulting in the desired scroll-linked parallax perspective effect. (Update: to get a deeper knowledge about adjusting and ordering chained transformations in CSS, this StackOverflow question might be a good start: Understanding translate after scale in CSS transforms

I have a div of 6400x3600 size. I'm using transform-origin: 50% 50% When I set the scale to 0.9, for the children to stay on the top left corner I need to translate to a negative value My reasoning was 6400 - 5760(90%) = 640 / 2 = 320... so…

(Dis-)advantages of old-school "parallax" approaches

But before accepting seemingly hacky necessities, I want to have another look at the seemingly simple landscape image effects again. Maybe there is some practical middle ground in between those and my previous explorations that still feel a bit like an overly complicated way to achieve the same result as setting background-attachment: fixed for the document body like in this classic W3Schools How-To, although in that case, the background would not move at all, while in the parallax scenario it moves at another speed which can be fine-tuned, plus we can optionally add more than one background layer when using perspective techniques.

Advantages of a fixed background attachment: only a few lines of code, very easy to understand, and from a theoretical stance we might even see the fixed background as a special edge case of a perspective scenario which a distance that approximates infinity, much like looking at the moon or the stars that don't seem to move at all when changing perspective.

2D transformations to approach 3D perspective

CSS Sticky Parallax Sections, a codepen by Ryan Mulligan is another example of a simplified pseduo-perspective approach without actually using three-dimensional transformations, but it's also another example of burying the actual code in beautiful unrelated styles and hiding JavaScript in the HTML tab.

The sticky background image movement only seems to work with a full-height (100vh) section in this codepen. If we can use the foreground content height instead, then it would be perfect. Maybe we can modify the background's top margin or position inside its sticky container parent to control its movement explicitly.

Accessibility: respecting prefers-reduced-motion

But there is another aspect that makes Ryan's codepen stand out: he cares about accessibility and uses a custom property to disable the perspective movement if the use prefers reduced motion:

@media (prefers-reduced-motion) {
  :root {
    --scale: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

Even more research ...

At that point I still have not been satisfied with mind findings and understanding so far. Every example had some disadvantage that made it hard to use in another context.

  • 3D perspective needs global body styles?
  • 2D scaleY is contained, but needs 100vh sections?
  • 2D fixed attachment does not move the background
  • older tutorials depend on costly outdated JS calculations

How can it be so hard to find one tutorial that sums up the concept and provide simple, portable examples for every alternative. Shouldn't there be something like the great grid and flexbox tutorials on MDN going beyond the brief perspective reference?

It looks like there aren't many great matches for your search

Proceeding my search, googling for "2d parallax approximation css", I came up with a lot of StackOverflow results, and another DEV article, written by Rob O'Leary in 2022:

Conclusion

As I "wasted" too much time on this topic already, this probably won't be the perfect roundup with ready-made recipes either - unless I do a complete rewrite some time - but I will publish it anyway, to share and back up my thoughts and findings.

Helpful tutorials and codepens

Finally, a pragmatic "beyond landscape" setup

My final, clean and minimal (enough) version behaves much like the infamous "awesome landscape", but it allows to use multiple CSS backgrounds or arbitrary DOM content that moves slower than the foreground content. Note that order matters, so the background layer should come first in our markup.

Choosing 2D for its simplicity and containment

My codepen is mostly based on Ryan Mulligan's tutorial, but it did help to follow Keith Clark's actual 3D approach to understand and compare the advantages and disadvantages of both and see my own conclusions verified when I finally found Rob O'Leary's DEV post. Any parallax approach has its limitations and caveats, and all recent posts seem to agree that we should rely on pure CSS without scroll position listeners to avoid costly callbacks known to cause performance problems.

Negative z-index is supported by every modern browser, and so are custom properties (CSS variables). We can even add background-attachment: fixed to improve legacy browser support with only one additional line of code.

Adapting the 2D approach beyond hero images

To avoid undesirable vertical margins when using this technique with variable content height paragraphs instead of hero-style page-height sections, I tried to clip the container and give it a corresponding margin to compensate for the foreground's margin-top: -50vh and the background's height: 100vh, and I changed the latter to min-height: 100vhto make it useful for content of arbitrary length. Note that values below 100vh can make our parallax section overlap other content unless we adjust the foreground's negative top margin accordingly.

Screenshot of overlapping content marked by colorful debugging borders

We can't clip the outer container's overflow-y without clipping the background too early at its bottom, but why? Because position: sticky takes it out of context so its natural height is reduced to 0 – unless we add bottom: 0 to span it to both edges of its parent container? Unfortunately that does not work in this case.

Aligning both layers using grid areas (as suggested in StackOverflow answers to position: absolute and parent height? removes the perspective effect, and as far as I know, there is no native CSS property to use an element's scroll position in a calc() function (yet).

Pragmatically, we could use JavaScript once, and only once, to determine our foreground layer's height and write it to a custom CSS property that we can use in a max() formula to increase our parallax background's min-height in case 100vh is not enough. It might also be a good idea to provide an absolute minimum height for our section, as the visual effect needs some space to unfold as intended anyway.

:root {
  --parallax-min-height: 20rem;
}

.parallax__layer--background {
  min-height: max(100vh, var(--parallax-min-height));
Enter fullscreen mode Exit fullscreen mode

So we have two variable custom properties, a scaling factor --parallax-scale that can be set to zero for accessibility reasons, and a --parallax-min-height that we can potentially increase based on a one-time measurement when the document has finished rendering (document.addEventListener('DOMContentLoaded' …) but that would go beyond a pure CSS solution.

Don't (up)scale the content for no reason

The original demo used to scale both the background and foreground, only slightly, and I wondered if that's changing the foreground content's sizes, thus breaking pixel-perfect design requirements. It doesn't, as it compensates the opposite scaling of the parent container.

Move and hide the overlapping top margin

We need some extra space to make the visual effect work properly, but we don't want that to break our layout if the effect is used in a normal text-with-images article context without page-height hero sections. So let's try and adjust our container's top margins and/or hide the overlap below the preceding element.

We could compensate the top offset reusing our min-height calculation like this:

.parallax__layer--foreground {
  margin-top: calc(-1 * max(100vh, var(--parallax-min-height)));
Enter fullscreen mode Exit fullscreen mode

But this makes both foreground and background scroll together without any offset until they reach the top of the viewport and the sticky position does its trick.

So we should either use the same negative top margin on the outer container and make sure its content gets hidden below the preceding element, by defining another negative z-index and adding position: relative, as the implicit default position: static disables any z-index directives. Alternatively, we could apply a positive z-index to the preceding sections which aren't 100% independent from our changes, as we will see further below.

.parallax__group {
  margin-top: calc(-1 * max(100vh, var(--parallax-min-height)));
  z-index: -1;
  position: relative;
Enter fullscreen mode Exit fullscreen mode

This margin-top still does not match perfectly, as it does not compensate our Y-transformation yet. Without bothering too much about properly converting an integer scaling factor to a rem or pixel value, I'll just hard-code the actual offset like this:

.parallax__group {
    3.75rem /* fixed offset adjustment */
    + 
    (-1     /* compensate background min-height */
      *
      max(100vh, var(--parallax-min-height))
    )
  );
Enter fullscreen mode Exit fullscreen mode

Now we're almost done: 😉😂

Intermediate state screenshot with overlapping content above the parallax scrolling section

Requirement: opaque background and zero margin siblings

Now there is a side effect: even if there was a CSS selector to target a previous element, our top margin adjustment introduces a new requirement that affects unrelated content. Any element(s) before our parallax scrolling section must have an opaque (non-transparent) background, otherwise the background decoration will shine through.

The same is probably also true if we adjust the bottom end of our effect section as well. Although this side effect breaks my idealistic modular requirement, it's "only a single background-color" – and zero margins (we can use padding instead). That's what most (atomic) design systems usually set anyway, just like the typical body { margin: 0; padding: 0, so I think this solution is good enough in practice.

/* recommended other elements' opaque background etc. */
body {
  margin: 0;
  padding: 0;
}

.header, .footer, .content {
  background-color: white;
  margin:0;
  padding: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

Same with the background's height and margin: it looks wrong and broken with a pattern of repeating circles, but nobody would notice when using a set of centered objects or the notorious "awesome landscape" image adjusted with object-fit: cover and faded out below a linear gradient, like every other tutorial and ready-to-use preset does.

Screenshot of sticky parallax codepen highlighting the linear background making the background image fade out

Now let's remove any debug stuff and see if it works, (re-)test alternative browsers, different viewports, and make sure that there is really is no parallax effect if the user prefers reduced motion!

See it in action: my new codepen

Source: codepen.io/openmindculture/pen/wvQevbd

Relevant code snippets:

<section class="parallax__group">
  <div class="parallax__layer parallax__layer--background">
  </div>
  <div class="parallax__layer parallax__layer--foreground">
Enter fullscreen mode Exit fullscreen mode
:root {
  --parallax-scale: 0.1;
  --parallax-min-height: 44rem;
  --container-offset-adjustment: 5rem;
}

/* disable if requested for accessibility reasons */
@media (prefers-reduced-motion) {
  :root {
    --parallax-scale: 0;
  }
}

/* container around parallax layers */
.parallax__group {
  position: relative;
  z-index: -1;
  transform-origin: center top;
  transform: scaleY(calc(1 - var(--parallax-scale)));
  margin-top: calc(
    var(--container-offset-adjustment)
    + 
    (-1
      *
      max(100vh, var(--parallax-min-height))
    )
  );
  margin-bottom: calc(-1 * var(--container-offset-adjustment));
}

.parallax__layer {
  transform-origin: center top;
  transform: scaleY(calc(1 / (1 - var(--parallax-scale))));
}

.parallax__layer--foreground {
  position: relative;
  top: 0;
}

.parallax__layer--background {
  position: sticky;
  z-index: -2;
  top: 0;
  transform-origin: center top;
  transform: scaleY(calc(1 / (1 - var(--parallax-scale))));
  height: 100%;
  min-height: 100vh;
  min-height: max(100vh, var(--parallax-min-height));
  width: 100vw;
  background-attachment: fixed; /* legacy fallback */
  background-image: url(…); 
}

/* recommended other elements' opaque background etc. */
body {
  margin: 0;
  padding: 0;
}

.header, .footer, .content {
  background-color: white;
  margin:0;
  padding: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

That's it. This is not the perfect best-case-fits-all tutorial that I hoped to write, and neither is my code. All I can say is that it's good enough and that it helped me to better understand the concept and the necessary CSS styles. Hopefully it can help and inspire others as well and show that even senior web developers don't always come up with a perfect solution.

Top comments (9)

Collapse
 
lebinhan profile image
Lê Bình An

This is absolutely an amazing post, the content is great and what even better is that you have spent your own time and effort to research and conduct it. Not only present the code to create the effect, you also explained each of them care fully and comparing each of them to find the best solution.

Among thounsands of copy-paste content on the internet, this stand-out like a shining gem.

Good work my programmer! You deserve a heart.

Collapse
 
ingosteinke profile image
Ingo Steinke

Thanks so much! I wasn't even sure if I wanted to publish this, but luckily I did :-)

Collapse
 
tleperou profile image
Thomas Lepérou

can't agree more with you

thanks @ingosteinke

Collapse
 
ingosteinke profile image
Ingo Steinke • Edited

As the pseudo-parallax approach based on 2d-Y-transformation can get quite hard to adjust (especially in a responsive CMS theme with many known unknowns), I revisited the real 3d approach using CSS perspective for a use case where I didn't have the double scrollbars problem, as the effect was designed to cover great parts of the page starting below the hero section.

I also find the 3d approach easier to comprehend. I have to admit that I never fully understood why and how exactly the 2d transformation of both foreground and background, plus the sticky positioning allowed the decorative background to shift against the foreground at all.

Here is a Codepen from August 8, 2023, featuring my latest experiment rewritten from a 2d-section to full-page 3d technique: codepen.io/openmindculture/pen/YzR...

Collapse
 
richard_moore_nz profile image
Richard Moore • Edited

Wow that is just genius! Is it possible to have a second parallax layer, for example image boxes, moving at a slightly different to the speed of the text boxes when scrolling, and how would that be implemented?

My contribution to the code is to add to the cards an entirely css keyframe fade in and out.

@keyframes reveal {
  0% {
    opacity: 0;
  }
  25% {
    opacity: 1;
  }
  75% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
.card {
/* existing css*/
  animation: reveal linear;
  animation-timeline: view();
}
Enter fullscreen mode Exit fullscreen mode

So many thanks for the code and the explanation.

Collapse
 
ingosteinke profile image
Ingo Steinke • Edited

Work in progress, continued: instead of 100%, body height should become 100vh or rather 100dvh using dynamic height to prevent mobile phone glitches, and we need an explicit html { overflow: hidden if we want to use scroll events of descendant elements.

html {
  height: 100%;
  overflow: hidden;
}
body {
  height: 100%;
  height: 100vh;
  height: 100dvh;
  overflow-x: hidden;
  overflow-y: scroll;
  transform-style: preserve-3d;
  perspective: 1px;
  perspective-origin: 0 0;
}
Enter fullscreen mode Exit fullscreen mode

This is the CSS that I used for kleiderordnung.berlin, an exceptional portfolio project described in my recent blog post.

Collapse
 
jsonkao profile image
Jason Kao

Thank you so much for this post!! I used your guidance for this interactive story and it worked out well. It wouldn't have happened if I hadn't seen this post.

In the end, it wasn't pure CSS — I did need JavaScript to compute the heights of the container, the background container, and the foreground container to know how much padding to put on my shorter foreground layer. But it's a onetime computation (for each resize).

Collapse
 
ingosteinke profile image
Ingo Steinke

Thanks for sharing your example! I like it!

Collapse
 
ingosteinke profile image
Ingo Steinke

Update: in an earlier version of my post and codepen, I had removed the foreground's scaling, only to find out later that it actually compensates its parent's scaling. I updated the text and code accordingly.