由 CSS 自定义属性驱动的视差效果

Avatar of Jhey Tompkins
Jhey Tompkins

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我的好朋友 Kent C. Dodds 最近发布了他的 新网站,其中投入了大量工作。我很幸运地得到了 Kent 的联系,他问我是否可以为该网站设计一些“奇思妙想”。✨

首先引起我注意的是登陆页面上 Kody(🐨)的大图像。他被各种物体包围着,这在我看来,就好像在说,“让我动起来!”

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.

我之前构建过响应光标移动的视差风格场景,但没有达到这个规模,也没有用于 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)

这给了我们一个 xy 值的范围,我们可以将其插入我们的 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。我们将使用蓝色背景对其进行样式设置,使其一目了然。我们传递一个回调函数,每次将 xy 的值映射到 bounds 时都会触发该函数。我们可以在回调函数中将这些值除以任何值或对它们执行任何操作。

但是,该演示存在一个问题。这些值超出了 -11 的范围。我们需要将这些值限制住。GreenSock 有一个 另一个实用程序方法 可以用于此。它等同于使用 Math.minMath.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-xratio-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 对象的乐趣所在——我们可以根据需要对其进行调整。

我们如何将图像放入这些项目中?嗯,创建每个项目的单独图像很诱人。但是,这会导致每个图像的大量网络请求,这不利于性能。相反,我们可以创建一个图像精灵。事实上,这正是我所做的。

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

然后为了保持响应性,我们可以在 CSS 中对 background-sizebackground-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%);
}

现在我们有了带有视差项目的响应式描摹场景!

剩下的就是删除描摹图像和背景颜色,并应用转换。

在第一个版本中,我以不同的方式使用了这些值。我让处理程序返回 -6060 之间的值。我们可以通过操作返回值在我们的处理程序中做到这一点。

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 的钩子。我们将一个回调函数传递给它,该回调函数接收 xy 值。我们还传递了 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 自定义属性驱动的流畅视差场景!这很有趣,因为 图像精灵已经存在很长时间了,但它们今天仍然有很多用途!

保持精彩!ʕ •ᴥ•ʔ