我的好朋友 Kent C. Dodds 最近发布了他的 新网站,其中投入了大量工作。我很幸运地得到了 Kent 的联系,他问我是否可以为该网站设计一些“奇思妙想”。✨
首先引起我注意的是登陆页面上 Kody(🐨)的大图像。他被各种物体包围着,这在我看来,就好像在说,“让我动起来!”

我之前构建过响应光标移动的视差风格场景,但没有达到这个规模,也没有用于 React 应用程序。这个方法的巧妙之处在于?我们可以仅使用两个 CSS 自定义属性来驱动整个效果。
让我们首先获取用户的鼠标光标位置。这非常简单,只需要
const UPDATE = ({ x, y }) => {
document.body.innerText = `x: ${x}; y: ${y}`
}
document.addEventListener('pointermove', UPDATE)
我们希望将这些值映射到中心点周围。例如,视口的左侧对于 x
应该是 -1
,右侧应该是 1
。我们可以引用一个元素,并使用映射函数计算其中心的值。在这个项目中,我能够使用 GSAP,这意味着使用它的一些实用函数。它们已经提供了用于此目的的 mapRange()
函数。传入两个范围,您将获得一个可用于获取映射值的函数。
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
如果我们想使用窗口作为容器元素怎么办?我们可以将值映射到它的宽度和高度。
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)
这给了我们一个 x
和 y
值的范围,我们可以将其插入我们的 CSS 中。请注意,我们如何将值除以 100
以获得小数值。当我们稍后将这些值与 CSS 集成时,这应该是有意义的。
现在,如果我们有一个元素想要针对该值进行映射,并在某个特定的范围内呢?换句话说,我们希望我们的处理程序查找元素的位置,计算邻近范围,然后将光标位置映射到该范围。这里的理想解决方案是创建一个为我们生成处理程序的函数。然后我们可以重复使用它。但是,出于本文的目的,我们正在“顺利执行”操作,其中我们避免类型检查或检查回调值等。
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)};`
}))
在这个演示中,我们的邻近距离是 100
。我们将使用蓝色背景对其进行样式设置,使其一目了然。我们传递一个回调函数,每次将 x
和 y
的值映射到 bounds
时都会触发该函数。我们可以在回调函数中将这些值除以任何值或对它们执行任何操作。
但是,该演示存在一个问题。这些值超出了 -1
和 1
的范围。我们需要将这些值限制住。GreenSock 有一个 另一个实用程序方法 可以用于此。它等同于使用 Math.min
和 Math.max
的组合。由于我们已经有了依赖项,因此没有必要重新发明轮子!我们可以在函数中限制值。但是,选择在回调函数中这样做会更灵活,正如我们即将看到的。
如果愿意,我们也可以使用 CSS clamp()
来做到这一点。😉
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))};
`
}))
现在我们有了限制后的值!
在这个演示中,调整邻近距离并拖动容器以查看处理程序的运行情况。
这就是该项目的大部分 JavaScript 代码!剩下的就是将这些值传递给 CSS。我们可以在回调函数中做到这一点。让我们使用名为 ratio-x
和 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))
现在我们有一些可以在 CSS 中使用的值,我们可以根据需要将它们与 calc()
组合起来。例如,此演示根据 y
值更改容器元素的比例。然后根据 x
值更新容器的 色相
。
这里巧妙的地方在于,JavaScript 不关心您如何使用这些值。它已经完成了自己的任务。这就是使用作用域自定义属性的魔力。
.container {
--hue: calc(180 - (var(--ratio-x, 0) * 180));
background: hsl(var(--hue, 25), 100%, 80%);
transform: scale(calc(2 - var(--ratio-y, 0)));
}
另一个有趣的点是考虑是否要限制值。在此演示中,如果我们不限制 x
,则可以在页面上的任何位置更新 色相
。
制作场景
我们已经掌握了这项技术!现在我们可以用它做任何我们想做的事情。这完全取决于您的想象力。我已经将此相同的设置用于很多事情。
到目前为止,我们的演示只对包含元素进行了更改。但是,我们不妨再次提到,自定义属性作用域的功能是强大的。
我的任务是在 Kent 的网站上让东西动起来。当我第一次看到 Kody 和一堆物体的图像时,我可以看到所有单独的部分都在做自己的事情——所有这些都由我们传入的这两个自定义属性驱动。但这看起来会是什么样子呢?关键是为我们容器的每个子元素使用内联自定义属性。
现在,我们可以更新我们的标记以包含一些子元素
<div class="container">
<div class="container__item"></div>
<div class="container__item"></div>
<div class="container__item"></div>
</div>
然后我们更新样式以包含 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))
;
}
那里重要的部分是如何在 transform
中使用 --ratio-x
和 --ratio-y
。每个项目通过 --move-x
等声明自己的移动和旋转级别。每个项目也使用作用域自定义属性 --x
和 --y
进行定位。
这就是这些 CSS 驱动的视差场景的关键。这一切都与将系数相互反弹有关!
如果我们用一些这些属性的内联值更新我们的标记,我们会得到以下结果
<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>
利用该作用域,我们可以得到类似这样的效果!这非常棒。它看起来几乎像一个盾牌。
但是,您如何获取静态图像并将其转换为响应式视差场景?首先,我们必须创建所有这些子元素并对其进行定位。为此,我们可以使用我们在 CSS 艺术中使用的 “描摹”技术。
下一个演示显示了我们在带有子元素的视差容器内使用的图像。为了解释这部分内容,我们创建了三个子元素并为它们提供了红色背景。该图像为 fixed
,并降低了 opacity
,并且与我们的视差容器对齐。
每个视差项目都是从 CONFIG
对象创建的。对于此演示,我使用 Pug 生成 HTML 中的这些项目以简洁起见。在最终项目中,我使用 React,我们稍后会展示。在这里使用 Pug 可以避免我单独编写所有内联 CSS 自定义属性。
-
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};`)
我们如何获得这些值?这需要大量的反复试验,并且非常耗时。为了使其具有响应性,定位和大小使用百分比值。
.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%);
}
一旦我们为所有项目创建了元素,我们就会得到如下所示的演示。这使用了最终作品中的 config 对象
如果内容没有完全对齐,请不要担心。反正一切都会动起来!这就是使用 config 对象的乐趣所在——我们可以根据需要对其进行调整。
我们如何将图像放入这些项目中?嗯,创建每个项目的单独图像很诱人。但是,这会导致每个图像的大量网络请求,这不利于性能。相反,我们可以创建一个图像精灵。事实上,这正是我所做的。

然后为了保持响应性,我们可以在 CSS 中对 background-size
和 background-position
属性使用百分比值。我们将其作为配置的一部分,然后也将这些值内联。配置结构可以是任何内容。
-
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};`)
更新我们的 CSS 以考虑这一点
.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%);
}
现在我们有了带有视差项目的响应式描摹场景!
剩下的就是删除描摹图像和背景颜色,并应用转换。
在第一个版本中,我以不同的方式使用了这些值。我让处理程序返回 -60
和 60
之间的值。我们可以通过操作返回值在我们的处理程序中做到这一点。
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))
)
}
然后,每个项目可以配置为
- x、y 和 z 位置,
- x 和 y 轴上的移动,以及
- x 和 y 轴上的旋转和平移。
CSS 转换非常长。它们看起来像这样
.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;
}
那个 --allow-motion
在做什么?演示中没有!没错。这是一个应用减少运动的小技巧。如果我们有 更喜欢“减少”运动的用户,我们可以使用系数来满足他们的需求。“减少”一词并不一定意味着“无”!
@media (prefers-reduced-motion: reduce) {
.parallax {
--allow-motion: 0.1;
}
}
@media (hover: none) {
.parallax {
--allow-motion: 0;
}
}
此“最终”演示展示了 --allow-motion
值如何影响场景。移动滑块以查看如何减少运动。
此演示还展示了另一个功能:能够选择一个“团队”,从而更改 Kody 的颜色。这里巧妙的部分在于,所有这些都只需要指向图像精灵的不同部分即可。
这就是创建 CSS 自定义属性驱动的视差效果的全部内容!但是,我确实说过这是我在 React 中构建的东西。是的,最后一个演示使用了 React。事实上,这在基于组件的环境中运行得非常好。我们有一个配置对象数组,我们可以将它们作为 children
以及任何转换系数传递到 <Parallax>
组件中。
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>
)
}
然后,如果您注意到了,那里有一个名为 useParallax
的钩子。我们将一个回调函数传递给它,该回调函数接收 x
和 y
值。我们还传递了 proximity
,它可以是 function
,以及要使用的元素。
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])
}
将其转换为自定义钩子意味着我可以在其他地方重复使用它。事实上,删除 GSAP 的使用使其成为一个不错的微型包机会。
最后,<ParallaxItem>
。这非常简单。它是一个将道具映射到内联 CSS 自定义属性的组件。在项目中,我选择将 background
属性映射到 ParallaxItem
的子元素。
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>
)
}
将所有这些结合起来,您最终可能会得到类似这样的结果
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>
)
这为我们提供了视差场景!
就是这样!
我们刚刚获取了一个静态图像并将其转换为由 CSS 自定义属性驱动的流畅视差场景!这很有趣,因为 图像精灵已经存在很长时间了,但它们今天仍然有很多用途!
保持精彩!ʕ •ᴥ•ʔ
非常酷的效果。有没有办法让它在手机上流畅运行?目前只在点击时有效,拖动无效。
更好:如果我们可以获取陀螺仪输入(倾斜手机)并使视差效果相应地做出反应,会怎么样呢…?
嗨!是的,移动端是一个“有趣”的问题。有两个选项
DeviceOrientation
并在可用时响应设备倾斜。pointermove
。我认为问题在于我没有在 CSS 中添加touch-action: none
。我会更新它。(已更新,并在最终演示中有效)感谢阅读!
同样,感谢阅读!
我创建了另一个演示,允许您连接到
DeviceOrientation
的beta
和gamma
值,以使移动响应设备移动。查看这里!
如果可以在移动设备上使用加速度计数据,那就太棒了。
我们可以!我应该在文章中提到它♂️
但是,如果
DeviceOrientation
可用。我们可以连接到事件并使用beta
和gamma
值进行值映射。然后我们可以根据设备倾斜/旋转移动视差。查看演示这里!
感谢阅读!保持出色!
ʕ •ᴥ•ʔ
他是不是从未在他的网站上实现它?现在只是静态版本。