DEV Community

Cover image for Parallax Powered by CSS Custom Properties
Jhey Tompkins
Jhey Tompkins

Posted on • Originally published at jhey.dev on

Parallax Powered by CSS Custom Properties

Good friend Kent C. Dodds has recently dropped his new website which had a lot of work go into it. I was fortunate enough that Kent reached out a while back and asked if I could come up with some "whimsy" for the site. ✨

One of the first things that drew my attention was the large image of Kody (🐨) on the landing page. He's surrounded by objects and that, to me, screamed, "Make me move!"

Kody surrounded by blue things

I have built parallax-style scenes before that respond to cursor movement, but not to this scale and not for a React application. The neat thing about this? We can power the whole thing with only two CSS custom properties.


Let's start by grabbing our user's cursor position. This is as straightforward as:

const UPDATE = ({ x, y }) => {
  document.body.innerText = `x: ${x}; y: ${y}`
}
document.addEventListener('pointermove', UPDATE)
Enter fullscreen mode Exit fullscreen mode

We want to map these values around a center point. For example, the left side of the viewport should be -1 for x, and 1 for the right side. We can reference an element and work out the value from its center using a mapping function. In this project, I was able to use GSAP and that meant using some of its utility functions. They already provide a mapRange() function for this purpose. Pass in two ranges and you'll get a function you can use to get the mapped value.

const mapRange = (inputLower, inputUpper, outputLower, outputUpper) => {
  const INPUT_RANGE = inputUpper - inputLower
  const OUTPUT_RANGE = outputUpper - outputLower
  return value => outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
}
// const MAPPER = mapRange(0, 100, 0, 10000)
// MAPPER(50) === 5000
Enter fullscreen mode Exit fullscreen mode

What if we want to use the window as the container element? We can map the value to the width and height of it.

import gsap from 'https://cdn.skypack.dev/gsap'

const BOUNDS = 100

const UPDATE = ({ x, y }) => {
  const boundX = gsap.utils.mapRange(0, window.innerWidth, -BOUNDS, BOUNDS, x)
  const boundY = gsap.utils.mapRange(0, window.innerHeight, -BOUNDS, BOUNDS, y)
  document.body.innerText = `x: ${Math.floor(boundX) / 100}; y: ${Math.floor(boundY) / 100};`
}

document.addEventListener('pointermove', UPDATE)
Enter fullscreen mode Exit fullscreen mode

That gives us a range of x and y values that we can plug into our CSS. Note how we are dividing the values by 100 to get a fractional value. This should make sense when we integrate these values with our CSS a little later.

Now, what if we have an element that we want to map that value against, and within a certain proximity? In other words, we want our handler to look up the position of the element, work out the proximity range, and then map the cursor position to that range. The ideal solution here is to create a function that generates our handler for us. Then we can reuse it. For the purpose of this article, though, we’re operating on a “happy path” where we are avoiding type checks or checking for the callback value, etc.

const CONTAINER = document.querySelector('.container')

const generateHandler = (element, proximity, cb) => ({x, y}) => {
  const bounds = 100
  const elementBounds = element.getBoundingClientRect()
  const centerX = elementBounds.left + elementBounds.width / 2
  const centerY = elementBounds.top + elementBounds.height / 2
  const boundX = gsap.utils.mapRange(centerX - proximity, centerX + proximity, -bounds, bounds, x)
  const boundY = gsap.utils.mapRange(centerY - proximity, centerY + proximity, -bounds, bounds, y)
  cb(boundX / 100, boundY / 100)
}

document.addEventListener('pointermove', generateHandler(CONTAINER, 100, (x, y) => {
  CONTAINER.innerText = `x: ${x.toFixed(1)}; y: ${y.toFixed(1)};`
}))
Enter fullscreen mode Exit fullscreen mode

In this demo, our proximity is 100 . We’ll style it with a blue background to make it obvious. We pass a callback that gets fired each time the values for x and y get mapped to the bounds. We can divide these values in the callback or do what we want with them.

But wait, there's an issue with that demo. The values go outside the bounds of -1 and 1. We need to clamp those values. GreenSock has another utility method we can use for this. It's the equal of using a combination of Math.min and Math.max. As we already have the dependency, there's no point in reinventing the wheel! We could clamp the values in the function. But, choosing to do so in our callback will be more flexible as we’ll show coming up.

We could do this with CSS clamp() if we’d like. 😉

document.addEventListener('pointermove', generateHandler(CONTAINER, 100, (x, y) => {
  CONTAINER.innerText = `
    x: ${gsap.utils.clamp(-1, 1, x.toFixed(1))};
    y: ${gsap.utils.clamp(-1, 1, y.toFixed(1))};
  `
}))
Enter fullscreen mode Exit fullscreen mode

Now we have clamped values!

In this demo, adjust the proximity and drag the container around to see how the handler holds up.

That's the majority of JavaScript for this project! All that's left to do is pass these values to CSS-land. And we can do that in our callback. Let’s use custom properties named ratio-x and ratio-y.

const UPDATE = (x, y) => {
  const clampedX = gsap.utils.clamp(-1, 1, x.toFixed(1))
  const clampedY = gsap.utils.clamp(-1, 1, y.toFixed(1))
  CONTAINER.style.setProperty('--ratio-x', clampedX)
  CONTAINER.style.setProperty('--ratio-y', clampedY)
  CONTAINER.innerText = `x: ${clampedX}; y: ${clampedY};`
}

document.addEventListener('pointermove', generateHandler(CONTAINER, 100, UPDATE))
Enter fullscreen mode Exit fullscreen mode

Now that we have some values we can use in our CSS, we can combine them with calc() any way we like. For example, this demo changes the scale of the container element based on the y value. It then updates the hue of the container based on the x value.

The neat thing here is that the JavaScript doesn't care about what you do with the values. It's done its part. That's the magic of using scoped custom properties.

.container {
  --hue: calc(180 - (var(--ratio-x, 0) * 180));
  background: hsl(var(--hue, 25), 100%, 80%);
  transform: scale(calc(2 - var(--ratio-y, 0)));
}
Enter fullscreen mode Exit fullscreen mode

Another interesting point is considering whether you want to clamp the values or not. In this demo, if we didn't clamp x, we could have the hue update wherever we are on the page.

Making a scene

We have the technique in place! Now we can do pretty much whatever we want with it. It's kinda wherever your imagination takes you. I've used this same set up for a bunch of things.

Our demos so far have only made changes to the containing element. But, as we may as well mention again, the power of custom property scope is epic.

My task was to make things move on Kent’s site. When I first saw the image of Kody with a bunch of objects, I could see all the individual pieces doing their own thing—all powered by those two custom properties that we pass in. How might that look though? The key is inline custom properties for each child of our container.

For now, we could update our markup to include some children:

<div class="container">
  <div class="container__item"></div>
  <div class="container__item"></div>
  <div class="container__item"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

Then we update the styles to include some scoped styles for container__item:

.container__item {
  position: absolute;
  top: calc(var(--y, 0) * 1%);
  left: calc(var(--x, 0) * 1%);
  height: calc(var(--size, 20) * 1px);
  width: calc(var(--size, 20) * 1px);
  background: hsl(var(--hue, 0), 80%, 80%);
  transition: transform 0.1s;
  transform:
    translate(-50%, -50%)
    translate(
      calc(var(--move-x, 0) * var(--ratio-x, 0) * 100%),
      calc(var(--move-y, 0) * var(--ratio-y, 0) * 100%)
    )
    rotate(calc(var(--rotate, 0) * var(--ratio-x, 0) * 1deg))
  ;
}
Enter fullscreen mode Exit fullscreen mode

The important part there is how we're making use of --ratio-x and --ratio-y inside the transform. Each item declares its own level of movement and rotation via --move-x , etc. Each item is also positioned with scoped custom properties, --x and --y.

That's the key to these CSS powered parallax scenes. It's all about bouncing coefficients against each other!

If we update our markup with some inline values for those properties, here’s what we get:

<div class="container">
  <div class="container__item" style="--move-x: -1; --rotate: 90; --x: 10; --y: 60; --size: 30; --hue: 220;"></div>
  <div class="container__item" style="--move-x: 1.6; --move-y: -2; --rotate: -45; --x: 75; --y: 20; --size: 50; --hue: 240;"></div>
  <div class="container__item" style="--move-x: -3; --move-y: 1; --rotate: 360; --x: 75; --y: 80; --size: 40; --hue: 260;"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

Leveraging that scope, we can get something like this! That’s pretty neat. It almost looks like a shield.

But, how do you take a static image and turn it into a responsive parallax scene? First, we're going to have to create all those child elements and position them. And to do this we can use the "tracing" technique we use with CSS art.

This next demo shows the image we’re using inside a parallax container with children. To explain this part, we've created three children and given them a red background. The image is fixed with a reduced opacity and lines up with our parallax container.

Each parallax item gets created from a CONFIG object. For this demo, I'm using Pug to generate these in HTML for brevity. In the final project, I'm using React which we can show later. Using Pug here saves me writing out all the inline CSS custom properties individually.

-
  const CONFIG = [
    {
      positionX: 50,
      positionY: 55,
      height: 59,
      width: 55,
    },
    {
      positionX: 74,
      positionY: 15,
      height: 17,
      width: 17,
    },
    {
      positionX: 12,
      positionY: 51,
      height: 24,
      width: 19,
    }
  ]

img(src="https://assets.codepen.io/605876/kody-flying_blue.png")
.parallax
  - for (const ITEM of CONFIG)
    .parallax__item(style=`--width: ${ITEM.width}; --height: ${ITEM.height}; --x: ${ITEM.positionX}; --y: ${ITEM.positionY};`)
Enter fullscreen mode Exit fullscreen mode

How do we get those values? It's a lot of trial and error and is definitely time consuming. To make it responsive, the positioning and sizing use percentage values.

.parallax {
  height: 50vmin;
  width: calc(50 * (484 / 479) * 1vmin); // Maintain aspect ratio where 'aspect-ratio' doesn't work to that scale.
  background: hsla(180, 50%, 50%, 0.25);
  position: relative;
}

.parallax__item {
  position: absolute;
  left: calc(var(--x, 50) * 1%);
  top: calc(var(--y, 50) * 1%);
  height: calc(var(--height, auto) * 1%);
  width: calc(var(--width, auto) * 1%);
  background: hsla(0, 50%, 50%, 0.5);
  transform: translate(-50%, -50%);
}
Enter fullscreen mode Exit fullscreen mode

Once we’ve made elements for all the items, we get something like the following demo. This uses the config object from the final work:

Don't worry if things aren’t perfectly lined up. Everything is going to be moving anyway! That's the joy of using a config object—we get tweak it how we like.

How do we get the image into those items? Well, it’s tempting to create separate images for each item. But, that would result in a lot of network requests for each image which is bad for performance. Instead, we can create an image sprite. In fact, that’s exactly what I did.

Kody Sprite

Then to keep things responsive, we can use a percentage value for the background-size and background-position properties in the CSS. We make this part of the config and then inline those values, too. The config structure can be anything.

-
  const ITEMS = [
    {
      identifier: 'kody-blue',
      backgroundPositionX: 84.4,
      backgroundPositionY: 50,
      size: 739,
      config: {
        positionX: 50,
        positionY: 54,
        height: 58,
        width: 55,
      },
    },
  ]

.parallax
  - for (const ITEM of ITEMS)
    .parallax__item(style=`--pos-x: ${ITEM.backgroundPositionX}; --pos-y: ${ITEM.backgroundPositionY}; --size: ${ITEM.size}; --width: ${ITEM.config.width}; --height: ${ITEM.config.height}; --x: ${ITEM.config.positionX}; --y: ${ITEM.config.positionY};`)
Enter fullscreen mode Exit fullscreen mode

Updating our CSS to account for this:

.parallax__item {
  position: absolute;
  left: calc(var(--x, 50) * 1%);
  top: calc(var(--y, 50) * 1%);
  height: calc(var(--height, auto) * 1%);
  width: calc(var(--width, auto) * 1%);
  transform: translate(-50%, -50%);
  background-image: url("kody-sprite.png");
  background-position: calc(var(--pos-x, 0) * 1%) calc(var(--pos-y, 0) * 1%);
  background-size: calc(var(--size, 0) * 1%);
}
Enter fullscreen mode Exit fullscreen mode

And now we have a responsive traced scene with parallax items!

All that's left to do is remove the tracing image and the background colors, and apply transforms.

In the first version, I used the values in a different way. I had the handler return values between -60 and 60. We can do that with our handler by manipulating the return values.

const UPDATE = (x, y) => {
  CONTAINER.style.setProperty(
    '--ratio-x',
    Math.floor(gsap.utils.clamp(-60, 60, x * 100))
  )
  CONTAINER.style.setProperty(
    '--ratio-y',
    Math.floor(gsap.utils.clamp(-60, 60, y * 100))
  )
}
Enter fullscreen mode Exit fullscreen mode

Then, each item can be configured for:

  • the x, y, and z positions,
  • movement on the x and y axis, and
  • rotation and translation on the x and y axis.

The CSS transforms are quite long. This is what they look like:

.parallax {
  transform: rotateX(calc(((var(--rx, 0) * var(--range-y, 0)) * var(--allow-motion)) * 1deg))
    rotateY(calc(((var(--ry, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg))
    rotate(calc(((var(--r, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg));
  transform-style: preserve-3d;
  transition: transform 0.25s;
}

.parallax__item {
  transform: translate(-50%, -50%)
    translate3d(
      calc(((var(--mx, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1%),
      calc(((var(--my, 0) * var(--range-y, 0)) * var(--allow-motion)) * 1%),
      calc(var(--z, 0) * 1vmin)
    )
    rotateX(calc(((var(--rx, 0) * var(--range-y, 0)) * var(--allow-motion)) * 1deg))
    rotateY(calc(((var(--ry, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg))
    rotate(calc(((var(--r, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg));
  transform-style: preserve-3d;
  transition: transform 0.25s;
}
Enter fullscreen mode Exit fullscreen mode

What’s that --allow-motion thing doing? That’s not in the demo! True. This is a little trick for applying reduced motion. If we have users who prefer "reduced" motion, we can cater for that with a coefficient. The word "reduced" doesn't have to mean "none" after all!

@media (prefers-reduced-motion: reduce) {
  .parallax {
    --allow-motion: 0.1;
  }
}
@media (hover: none) {
  .parallax {
    --allow-motion: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

This "final" demo shows how the --allow-motion value affects the scene. Move the slider to see how you can reduce the motion.

This demo also shows off another feature: the ability to choose a "team" that changes Kody’s color. The neat part here is that all that requires is pointing to a different part of our image sprite.

And that's it for creating a CSS custom property powered parallax! But, I did mention this was something I built in React. And yes, that last demo uses React. In fact, this worked quite well in a component-based environment. We have an array of configuration objects and we can pass them into a <Parallax> component as children along with any transform coefficients.

const Parallax = ({
  config,
  children,
}: {
  config: ParallaxConfig
  children: React.ReactNode | React.ReactNode[]
}) => {
  const containerRef = React.useRef<HTMLDivElement>(null)
  useParallax(
    (x, y) => {
      containerRef.current.style.setProperty(
        '--range-x', Math.floor(gsap.utils.clamp(-60, 60, x * 100))
      )
      containerRef.current.style.setProperty(
        '--range-y', Math.floor(gsap.utils.clamp(-60, 60, y * 100))
      )
    },
    containerRef,
    () => window.innerWidth * 0.5,
)

  const containerStyle = {
    '--r': config.rotate,
    '--rx': config.rotateX,
    '--ry': config.rotateY,
  }
  return (
    <div
      ref={containerRef}
      className="parallax"
      style={
        containerStyle as ContainerCSS
      }
    >
      {children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then, if you spotted it, there's a hook in there called useParallax. We pass a callback into this that receives the x and y value. We also pass in the proximity which can be a function, and the element to use.

const useParallax = (callback, elementRef, proximityArg = 100) => {
  React.useEffect(() => {
    if (!elementRef.current || !callback) return
    const UPDATE = ({ x, y }) => {
      const bounds = 100
      const proximity = typeof proximityArg === 'function' ? proximityArg() : proximityArg
      const elementBounds = elementRef.current.getBoundingClientRect()
      const centerX = elementBounds.left + elementBounds.width / 2
      const centerY = elementBounds.top + elementBounds.height / 2
      const boundX = gsap.utils.mapRange(centerX - proximity, centerX + proximity, -bounds, bounds, x)
      const boundY = gsap.utils.mapRange(centerY - proximity, centerY + proximity, -bounds, bounds, y)
      callback(boundX / 100, boundY / 100)
    }
    window.addEventListener('pointermove', UPDATE)
    return () => {
      window.removeEventListener('pointermove', UPDATE)
    }
  }, [elementRef, callback])
}
Enter fullscreen mode Exit fullscreen mode

Spinning this into a custom hook means I can reuse it elsewhere. In fact, removing the use of GSAP makes it a nice micro-package opportunity.

Lastly, the <ParallaxItem>. This is pretty straightforward. It's a component that maps the props into inline CSS custom properties. In the project, I opted to map the background properties to a child of the ParallaxItem.

const ParallaxItem = ({
  children,
  config,
}: {
  config: ParallaxItemConfig
  children: React.ReactNode | React.ReactNode[]
}) => {
  const params = {...DEFAULT_CONFIG, ...config}
  const itemStyle = {
    '--x': params.positionX,
    '--y': params.positionY,
    '--z': params.positionZ,
    '--r': params.rotate,
    '--rx': params.rotateX,
    '--ry': params.rotateY,
    '--mx': params.moveX,
    '--my': params.moveY,
    '--height': params.height,
    '--width': params.width,
  }
  return (
    <div
      className="parallax__item absolute"
      style={
        itemStyle as ItemCSS
      }
    >
      {children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Tie all that together and you could end up with something like this:

const ITEMS = [
  {
    identifier: 'kody-blue',
    backgroundPositionX: 84.4,
    backgroundPositionY: 50,
    size: 739,
    config: {
      positionX: 50,
      positionY: 54,
      moveX: 0.15,
      moveY: -0.25,
      height: 58,
      width: 55,
      rotate: 0.01,
    },
  },
  ...otherItems
]

const KodyConfig = {
  rotate: 0.01,
  rotateX: 0.1,
  rotateY: 0.25,
}

const KodyParallax = () => (
  <Parallax config={KodyConfig}>
    {ITEMS.map(item => (
      <ParallaxItem key={item.identifier} config={item.config} />
    ))}
  </Parallax>
)
Enter fullscreen mode Exit fullscreen mode

Which gives us our parallax scene!

That’s it!

We just took a static image and turned it into a slick parallax scene powered by CSS custom properties! It’s funny because image sprites have been around a long time, but they still have a lot of use today!

Stay Awesome! ʕ •ᴥ•ʔ

Top comments (5)

Collapse
 
guscarpim profile image
Gustavo Scarpim

Wow!
Very simple and easy, good job!

Collapse
 
goomerr profile image
Goomerr

Wow, great

Collapse
 
jh3y profile image
Jhey Tompkins

Thank you! 🙏 \ʕ •ᴥ•ʔ/

Collapse
 
ksengine profile image
Kavindu Santhusa

Thanks, I want to tweet about this. I there is a tweet, Please give me the link, and I'll retweet it.

Collapse
 
jh3y profile image
Jhey Tompkins

There is this one from the DEV Twitter ✨ : twitter.com/ThePracticalDev/status...