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

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.

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 `
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 `
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! ʕ •ᴥ•ʔ
Ziemlich cooler Effekt. Gibt es eine Möglichkeit, ihn auf Mobilgeräten flüssig zum Laufen zu bringen? Derzeit funktioniert er nur beim Tippen, nicht beim Ziehen
Besser: Was wäre, wenn wir Gyroskopeingaben (Kippen des Telefons) nutzen könnten und den Parallax-Effekt entsprechend reagieren lassen?
Hey! Ja, Mobil ist ein „interessantes“ Thema. Zwei Optionen
Danke fürs Lesen!
Auch, danke fürs Lesen!
Ich habe eine weitere Demo erstellt, die es Ihnen ermöglicht, die `beta`- und `gamma`-Werte von `DeviceOrientation` zu nutzen, um die Bewegung auf die Bewegung des Geräts reagieren zu lassen.
Schauen Sie sich das hier an!
Es wäre erstaunlich, wenn Sie es auf einem Mobilgerät mit Beschleunigerdaten verwenden könnten.
Das können wir! Das hätte ich im Artikel erwähnen sollen ♂️
Aber wenn `DeviceOrientation` verfügbar ist. Können wir uns mit dem Ereignis verbinden und die `beta`- und `gamma`-Werte mit unserer Werteabbildung verwenden. Dann können wir den Parallaxeffekt basierend auf der Neigung/Drehung des Geräts steuern.
Schauen Sie sich die Demo hier an!
Danke fürs Lesen! Bleib großartig!
ʕ •ᴥ•ʔ
Hat er es jemals auf seiner Website implementiert? Es ist jetzt nur noch die statische Version.