由 CSS 自定义属性支持的视差

2025-06-07

由 CSS 自定义属性支持的视差

好友 Kent C. Dodds 最近上线了他的新网站,这个网站耗费了我不少心血。很幸运,Kent 不久前联系了我,问我能否为这个网站想点“奇思妙想”。✨

首先吸引我注意的是登陆页面上 Kody (🐨) 的大幅图片。他周围环绕着各种物品,这对我来说就像在尖叫:“让我动起来!”

科迪被蓝色的东西包围着

我之前也构建过一些响应光标移动的视差场景,但规模没有达到这种程度,而且不适合 React 应用。这个场景的妙处在哪里?我们只需两个 CSS 自定义属性就能实现。


让我们从获取用户的光标位置开始。这很简单:

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

我们希望将这些值映射到一个中心点。例如,视口左侧的 应该是-1x右侧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
Enter fullscreen mode Exit fullscreen mode

如果我们想使用窗口作为容器元素怎么办?我们可以将值映射到它的宽度和高度。

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

这给了我们一系列可以插入到 CSS 中的x和值。注意我们如何用 除以来得到一个分数值。稍后我们将这些值与 CSS 集成时,这应该会很有意义。y100

现在,如果我们想要将某个元素的值映射到该元素上,并且该元素的距离在某个范围内,该怎么办?换句话说,我们希望处理程序查找元素的位置,计算出距离范围,然后将光标位置映射到该范围。理想的解决方案是创建一个函数来生成处理程序。然后我们可以重用它。不过,就本文而言,我们采用“快乐路径”,避免类型检查或检查回调值等。

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

在这个演示中,我们的接近度是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))};
  `
}))
Enter fullscreen mode Exit fullscreen mode

现在我们已经限制了值!

在此演示中,调整接近度并拖动容器以查看处理程序如何保持。

这就是本项目的大部分 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))
Enter fullscreen mode Exit fullscreen mode

现在我们已经有了一些可以在 CSS 中使用的值,可以按照calc()我们喜欢的方式组合它们。例如,这个演示会根据值更改容器元素的比例。然后,它还会根据值y更新容器的huex

巧妙之处在于,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)));
}
Enter fullscreen mode Exit fullscreen mode

另一个有趣的点是考虑是否要限制值。在这个演示中,如果我们不限制值,那么无论我们在页面上的哪个位置,x都可以进行更新。hue

制造场景

我们已经掌握了技术!现在我们几乎可以用它做任何想做的事情了。就像你的想象力可以带你去任何地方一样。我用同样的设置做过很多事情。

到目前为止,我们的演示仅对包含元素进行了更改。但是,我们不妨再次强调,自定义属性作用域的威力是史诗级的

我的任务是让 Kent 网站上的事物动起来。当我第一次看到 Kody 和一堆物体的图片时,我能看到所有单独的部分都在各自行动——这一切都由我们传入的两个自定义属性驱动。看起来会是什么样子呢?关键在于我们容器的每个子元素都内联了自定义属性。

现在,我们可以更新标记以包含一些子项:

<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

然后我们更新样式以包含一些范围样式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

这里最重要的部分是我们如何在 中使用--ratio-x。每个项目都通过 等方式声明其自身的移动和旋转级别。每个项目还使用范围限定的自定义属性和进行定位--ratio-ytransform--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>
Enter fullscreen mode Exit fullscreen mode

利用这个瞄准镜,我们可以得到这样的东西!真漂亮。它看起来几乎像个盾牌。

但是,如何将静态图像转换为响应式视差场景呢?首先,我们必须创建所有子元素并定位它们。为此,我们可以使用CSS art 中使用的“描摹”技术

下一个演示展示了我们在带有子元素的视差容器中使用的图像。为了解释这一部分,我们创建了三个子元素,并赋予它们红色背景。图像经过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};`)
Enter fullscreen mode Exit fullscreen mode

我们如何获取这些值?这需要大量的反复尝试,而且肯定很耗时。为了使其具有响应性,定位和大小都使用百分比值。

.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

一旦我们为所有项目创建了元素,我们就会得到类似以下演示的内容。它使用了最终作品中的配置对象:

如果一切没有完美对齐,也不用担心。反正一切都会动起来!这就是使用配置对象的乐趣所在——我们可以随心所欲地调整它。

我们如何将图片放入这些项目中?嗯,为每个项目创建单独的图片很诱人。但是,这会导致每张图片都产生大量的网络请求,从而降低性能。相反,我们可以创建一个图片精灵。事实上,我就是这么做的。

科迪雪碧

然后,为了保持响应式,我们可以在 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};`)
Enter fullscreen mode Exit fullscreen mode

更新我们的 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%);
}
Enter fullscreen mode Exit fullscreen mode

现在我们有一个带有视差项目的响应式追踪场景!

剩下要做的就是删除跟踪图像和背景颜色,然后应用变换。

在第一个版本中,我使用了不同的值。我让处理程序返回介于-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))
  )
}
Enter fullscreen mode Exit fullscreen mode

然后,每个项目可以配置为:

  • 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;
}
Enter fullscreen mode Exit fullscreen mode

--allow-motion玩意儿是干啥的?演示里没这玩意儿!没错。这是个应用减弱动效的小技巧。如果有用户喜欢“减弱”动效,我们可以用系数来满足需求。毕竟,“减弱”这个词并不一定意味着“无”!

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

这个“最终”演示展示了该--allow-motion值如何影响场景。移动滑块来查看如何减少运动。

这个演示还展示了另一个功能:可以选择一支“队伍”来改变科迪的颜色。这里巧妙之处在于,只需要指向图像精灵的不同部分即可。

这就是创建 CSS 自定义属性驱动的视差效果!不过,我之前提到过,这是我用 React 构建的。没错,上一个演示也用到了 React。事实上,这在基于组件的环境中运行得相当好。我们有一个配置对象数组,可以将它们与任何变换系数一起传递给<Parallax>组件。children

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

然后,如果你注意到了,里面有一个名为 的钩子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])
}
Enter fullscreen mode Exit fullscreen mode

将其拆分成自定义钩子意味着我可以在其他地方重复使用它。事实上,移除 GSAP 的使用,为它提供了一个绝佳的微包机会。

最后,这个<ParallaxItem>。这很简单。它是一个将 props 映射到内联 CSS 自定义属性的组件。在项目中,我选择将background属性映射到 的子组件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

将所有这些结合在一起,你最终会得到如下结果:

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

这给了我们视差场景!

就是这样!

我们刚刚把一张静态图片变成了一个由 CSS 自定义属性驱动的精美视差场景!这很有意思,因为图像精灵虽然已经存在很久了,但至今仍然用途广泛!

保持精彩!ʕ •ᴥ•ʔ

文章来源:https://dev.to/jh3y/parallax-powered-by-css-custom-properties-3p83
PREV
核心 Java/Java 理论目录
NEXT
面向投资银行资深专业人士的 20 多个核心 Java 面试问题