Parallax Powered by CSS Custom Properties

Avatar of Jhey Tompkins
Jhey Tompkins am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Der gute Freund Kent C. Dodds hat kürzlich seine neue Website veröffentlicht, in die sehr viel Arbeit geflossen ist. Ich hatte das Glück, dass Kent mich vor einiger Zeit kontaktiert und gefragt hat, ob ich mir etwas „Schwungvolles“ für die Seite ausdenken könnte. ✨

Eines der ersten Dinge, das meine Aufmerksamkeit erregte, war das große Bild von Kody (🐨) auf der Landing Page. Er ist von Objekten umgeben, und das schrie für mich: „Mach mich lebendig!“

Life-like illustration of an animatronic panda in a warn jacket and riding a snowboard while surrounded by a bunch of objects, like leaves, skis, and other gadgets.

Ich habe schon zuvor Parallax-artige Szenen erstellt, die auf Mausbewegungen reagieren, aber nicht in diesem Umfang und nicht für eine React-Anwendung. Das Coole daran? Wir können das Ganze mit nur zwei CSS Custom Properties steuern.


Beginnen wir damit, die Cursorposition unseres Benutzers zu erfassen. Das ist ganz einfach wie

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

Wir möchten diese Werte um einen Mittelpunkt herum abbilden. Zum Beispiel sollte die linke Seite des Viewports für `x` `-1` und für die rechte Seite `1` sein. Wir können ein Element referenzieren und den Wert von dessen Mitte aus mit einer Abbildungsfunktion ermitteln. In diesem Projekt konnte ich GSAP verwenden, was die Nutzung einiger seiner Hilfsfunktionen bedeutete. Sie stellen für diesen Zweck bereits eine `mapRange()`-Funktion bereit. Wenn Sie zwei Bereiche übergeben, erhalten Sie eine Funktion, mit der Sie den abgebildeten Wert abrufen können.

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

Was ist, wenn wir das Fenster als Containerelement verwenden möchten? Wir können den Wert auf die Breite und Höhe davon abbilden.

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)

Das gibt uns einen Bereich von `x`- und `y`-Werten, die wir in unser CSS einfügen können. Beachten Sie, wie wir die Werte durch `100` teilen, um einen Bruchteilswert zu erhalten. Dies sollte Sinn ergeben, wenn wir diese Werte etwas später in unser CSS integrieren.

Was ist, wenn wir ein Element haben, gegen das wir diesen Wert abbilden möchten, und zwar innerhalb einer bestimmten Nähe? Mit anderen Worten, wir möchten, dass unser Handler die Position des Elements abruft, den Entfernungsbereich ermittelt und dann die Cursorposition auf diesen Bereich abbildet. Die ideale Lösung hierfür ist die Erstellung einer Funktion, die unseren Handler für uns generiert. Dann können wir ihn wiederverwenden. Für diesen Artikel arbeiten wir jedoch auf einem „Happy Path“, bei dem wir Typüberprüfungen oder die Überprüfung des Callback-Werts usw. vermeiden.

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)};`
}))

In dieser Demo beträgt unsere Nähe `100`. Wir stylen sie mit einem blauen Hintergrund, um sie deutlich zu machen. Wir übergeben einen Callback, der jedes Mal aufgerufen wird, wenn die Werte für `x` und `y` auf die `bounds` abgebildet werden. Wir können diese Werte im Callback aufteilen oder tun, was immer wir wollen.

Aber warten Sie mal, diese Demo hat ein Problem. Die Werte gehen über die Grenzen von `-1` und `1` hinaus. Wir müssen diese Werte begrenzen. GreenSock hat eine weitere Hilfsmethode, die wir dafür verwenden können. Sie entspricht der Verwendung einer Kombination aus `Math.min` und `Math.max`. Da wir die Abhängigkeit bereits haben, gibt es keinen Grund, das Rad neu zu erfinden! Wir könnten die Werte in der Funktion begrenzen. Aber die Wahl, dies in unserem Callback zu tun, wird flexibler sein, wie wir im Folgenden zeigen werden.

Wir könnten das auch mit CSS `clamp()` machen, wenn wir wollten. 😉

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))};
  `
}))

Jetzt haben wir begrenzte Werte!

In dieser Demo können Sie die Nähe einstellen und den Container herumziehen, um zu sehen, wie sich der Handler verhält.

Das ist der Großteil des JavaScripts für dieses Projekt! Alles, was noch zu tun ist, ist, diese Werte an die CSS-Welt zu übergeben. Und das können wir in unserem Callback tun. Verwenden wir benutzerdefinierte Eigenschaften namens `ratio-x` und `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))

Jetzt, da wir einige Werte haben, die wir in unserem CSS verwenden können, können wir sie beliebig mit `calc()` kombinieren. Zum Beispiel ändert diese Demo die Skalierung des Containerelements basierend auf dem `y`-Wert. Dann aktualisiert sie die `hue` des Containers basierend auf dem `x`-Wert.

Das Coole daran ist, dass das JavaScript sich nicht darum kümmert, was Sie mit den Werten machen. Es hat seinen Teil erledigt. Das ist die Magie der Verwendung von skalierten benutzerdefinierten Eigenschaften.

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

Ein weiterer interessanter Punkt ist die Überlegung, ob Sie die Werte begrenzen möchten oder nicht. In dieser Demo könnten wir, wenn wir `x` nicht begrenzen, die `hue` überall auf der Seite aktualisieren lassen.

Eine Szene erstellen

Wir haben die Technik im Einsatz! Jetzt können wir damit so ziemlich alles machen, was wir wollen. Es ist irgendwie, wohin Ihre Fantasie Sie führt. Ich habe dieses gleiche Setup für viele Dinge verwendet.

Unsere bisherigen Demos haben nur Änderungen am enthaltenden Element vorgenommen. Aber, wie wir es auch wieder erwähnen können, ist die Macht des Custom Property Scopes episch.

Meine Aufgabe war es, die Dinge auf Kents Seite zu bewegen. Als ich zum ersten Mal das Bild von Kody mit einer Reihe von Objekten sah, konnte ich alle einzelnen Teile sehen, die ihr Eigenleben führten – alles angetrieben von diesen beiden benutzerdefinierten Eigenschaften, die wir hineinbringen. Wie könnte das aussehen? Der Schlüssel sind Inline-Custom-Properties für jedes Kind unseres Containers.

Vorerst könnten wir unser Markup aktualisieren, um einige Kinder einzubauen

<div class="container">
  <div class="container__item"></div>
  <div class="container__item"></div>
  <div class="container__item"></div>
</div>

Dann aktualisieren wir die Styles, um einige gescripte Styles für `container__item` einzuschließen

.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))
  ;
}

Der wichtige Teil dabei ist, wie wir `--ratio-x` und `--ratio-y` innerhalb von `transform` verwenden. Jedes Element deklariert seinen eigenen Bewegungs- und Rotationsgrad über `--move-x` usw. Jedes Element wird auch mit gescripte Custom Properties, `--x` und `--y`, positioniert.

Das ist der Schlüssel zu diesen CSS-gesteuerten Parallax-Szenen. Es geht darum, Koeffizienten gegeneinanderprallen zu lassen!

Wenn wir unser Markup mit einigen Inline-Werten für diese Eigenschaften aktualisieren, erhalten wir Folgendes:

<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>

Wenn wir diesen Scope nutzen, können wir etwas wie das hier bekommen! Das ist ziemlich nett. Es sieht fast wie ein Schild aus.

Aber wie verwandelt man ein statisches Bild in eine responsive Parallax-Szene? Zuerst müssen wir alle Kindelemente erstellen und positionieren. Und dafür können wir die „Tracing“-Technik verwenden, die wir bei CSS-Kunst einsetzen.

Die nächste Demo zeigt das Bild, das wir innerhalb eines Parallax-Containers mit Kindern verwenden. Um diesen Teil zu erklären, haben wir drei Kinder erstellt und ihnen einen roten Hintergrund gegeben. Das Bild ist `fixed` mit einer reduzierten `opacity` und passt zu unserem Parallax-Container.

Jedes Parallax-Element wird aus einem `CONFIG`-Objekt erstellt. Für diese Demo verwende ich Pug, um diese der Kürze halber in HTML zu generieren. Im endgültigen Projekt verwende ich React, was wir später zeigen können. Die Verwendung von Pug hier erspart mir das mühsame manuelle Schreiben all der Inline-CSS-Custom-Properties.

-
  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};`)

Woher bekommen wir diese Werte? Es ist viel Ausprobieren und definitiv zeitaufwendig. Um es responsiv zu machen, verwenden die Positionierung und das Sizing Prozentwerte.

.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%);
}

Sobald wir Elemente für alle Elemente erstellt haben, erhalten wir etwas Ähnliches wie die folgende Demo. Diese verwendet das Config-Objekt aus der endgültigen Arbeit

Machen Sie sich keine Sorgen, wenn die Dinge nicht perfekt ausgerichtet sind. Alles wird sich sowieso bewegen! Das ist die Freude an der Verwendung eines Config-Objekts – wir können es nach Belieben anpassen.

Wie bekommen wir das Bild in diese Elemente? Nun, es ist verlockend, separate Bilder für jedes Element zu erstellen. Aber das würde zu vielen Netzwerkanfragen für jedes Bild führen, was schlecht für die Leistung ist. Stattdessen können wir einen Bild-Sprite erstellen. Tatsächlich habe ich genau das getan.

An image sprite of the original Kody image, showing each object and and Kody lined up from left to right.

Um es dann responsiv zu halten, können wir einen Prozentwert für die `background-size` und `background-position` Eigenschaften im CSS verwenden. Wir machen dies zu einem Teil der Konfiguration und fügen auch diese Werte inline ein. Die Konfigurationsstruktur kann beliebig sein.

-
  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};`)

Wenn wir unser CSS entsprechend aktualisieren

.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%);
}

Und jetzt haben wir eine responsive getrackte Szene mit Parallax-Elementen!

Alles, was noch zu tun ist, ist das Tracing-Bild und die Hintergrundfarben zu entfernen und Transformationen anzuwenden.

In der ersten Version habe ich die Werte anders verwendet. Ich ließ den Handler Werte zwischen `-60` und `60` zurückgeben. Das können wir mit unserem Handler erreichen, indem wir die Rückgabewerte manipulieren.

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))
  )
}

Dann kann jedes Element konfiguriert werden für

  • die x-, y- und z-Positionen,
  • Bewegung auf der x- und y-Achse und
  • Rotation und Translation auf der x- und y-Achse.

Die CSS-Transformationen sind ziemlich lang. So sehen sie aus

.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;
}

Was macht dieses `--allow-motion`-Ding? Das ist nicht in der Demo! Stimmt. Das ist ein kleiner Trick, um reduzierte Bewegungen anzuwenden. Wenn wir Benutzer haben, die „reduzierte“ Bewegungen bevorzugen, können wir dem mit einem Koeffizienten Rechnung tragen. Das Wort „reduziert“ muss schließlich nicht „keine“ bedeuten!

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

Diese „finale“ Demo zeigt, wie sich der Wert `--allow-motion` auf die Szene auswirkt. Bewegen Sie den Schieberegler, um zu sehen, wie Sie die Bewegung reduzieren können.

Diese Demo zeigt auch eine weitere Funktion: die Möglichkeit, ein „Team“ auszuwählen, das die Farbe von Kody ändert. Das Coole daran ist, dass dazu nur das Ansteuern eines anderen Teils unseres Bild-Sprites erforderlich ist.

Und das war's für die Erstellung eines Parallax mit CSS Custom Properties! Aber ich erwähnte, dass dies etwas ist, das ich in React erstellt habe. Und ja, die letzte Demo verwendet React. Tatsächlich funktionierte dies in einer komponenten-basierten Umgebung recht gut. Wir haben ein Array von Konfigurationsobjekten und können diese als `children` an eine ``-Komponente übergeben, zusammen mit beliebigen Transformationskoeffizienten.

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,
)

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

Dann, falls Sie es bemerkt haben, gibt es einen Hook namens `useParallax`. Diesem übergeben wir einen Callback, der den `x`- und `y`-Wert erhält. Wir übergeben auch die `proximity`, die eine `function` sein kann, und das zu verwendende Element.

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])
}

Das Auslagern in einen benutzerdefinierten Hook bedeutet, dass ich ihn woanders wiederverwenden kann. Tatsächlich macht die Entfernung der GSAP-Nutzung es zu einer schönen Gelegenheit für ein Mikro-Paket.

Zuletzt die ``. Das ist ziemlich unkompliziert. Es ist eine Komponente, die die Props in Inline-CSS-Custom-Properties umwandelt. Im Projekt habe ich mich entschieden, die `background`-Eigenschaften auf ein Kind der `ParallaxItem` abzubilden.

const ParallaxItem = ({
  children,
  config,
}: {
  config: ParallaxItemConfig
  children: React.ReactNode | React.ReactNode[]
}) => {
  const params = {...DEFAULT_CONFIG, ...config}
  return (
    <div
      className='parallax__item absolute'
      style={
        {
          '--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,
        } as ItemCSS
      }
    >
      {children}
    </div>
  )
}

Wenn Sie all das zusammenfügen, könnten Sie am Ende etwas wie das hier bekommen

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 KodyParallax = () => (
  <Parallax config={{
    rotate: 0.01,
    rotateX: 0.1,
    rotateY: 0.25,
  }}>
    {ITEMS.map(item => (
      <ParallaxItem key={item.identifier} config={item.config} />
    ))}
  </Parallax>
)

Was uns unsere Parallax-Szene gibt!

Das ist alles!

Wir haben gerade ein statisches Bild genommen und es in eine schicke Parallax-Szene verwandelt, die von CSS Custom Properties angetrieben wird! Es ist lustig, weil Bild-Sprites schon lange existieren, aber sie haben immer noch viel Nutzen heutzutage!

Bleib großartig! ʕ •ᴥ•ʔ