由 CSS 自定义属性支持的视差
好友 Kent C. Dodds 最近上线了他的新网站,这个网站耗费了我不少心血。很幸运,Kent 不久前联系了我,问我能否为这个网站想点“奇思妙想”。✨
首先吸引我注意的是登陆页面上 Kody (🐨) 的大幅图片。他周围环绕着各种物品,这对我来说就像在尖叫:“让我动起来!”
我之前也构建过一些响应光标移动的视差场景,但规模没有达到这种程度,而且不适合 React 应用。这个场景的妙处在哪里?我们只需两个 CSS 自定义属性就能实现。
让我们从获取用户的光标位置开始。这很简单:
const UPDATE = ({ x, y }) => {
document.body.innerText = `x: ${x}; y: ${y}`
}
document.addEventListener('pointermove', UPDATE)
我们希望将这些值映射到一个中心点。例如,视口左侧的 应该是-1
,x
右侧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)
这给了我们一系列可以插入到 CSS 中的x
和值。注意我们如何用 除以来得到一个分数值。稍后我们将这些值与 CSS 集成时,这应该会很有意义。y
100
现在,如果我们想要将某个元素的值映射到该元素上,并且该元素的距离在某个范围内,该怎么办?换句话说,我们希望处理程序查找元素的位置,计算出距离范围,然后将光标位置映射到该范围。理想的解决方案是创建一个函数来生成处理程序。然后我们可以重用它。不过,就本文而言,我们采用“快乐路径”,避免类型检查或检查回调值等。
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
更新容器的。hue
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
都可以进行更新。hue
制造场景
我们已经掌握了技术!现在我们几乎可以用它做任何想做的事情了。就像你的想象力可以带你去任何地方一样。我用同样的设置做过很多事情。
到目前为止,我们的演示仅对包含元素进行了更改。但是,我们不妨再次强调,自定义属性作用域的威力是史诗级的。
我的任务是让 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))
;
}
这里最重要的部分是我们如何在 中使用--ratio-x
和。每个项目都通过 等方式声明其自身的移动和旋转级别。每个项目还使用范围限定的自定义属性和进行定位。--ratio-y
transform
--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 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};`)
我们如何获取这些值?这需要大量的反复尝试,而且肯定很耗时。为了使其具有响应性,定位和大小都使用百分比值。
.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%);
}
一旦我们为所有项目创建了元素,我们就会得到类似以下演示的内容。它使用了最终作品中的配置对象:
如果一切没有完美对齐,也不用担心。反正一切都会动起来!这就是使用配置对象的乐趣所在——我们可以随心所欲地调整它。
我们如何将图片放入这些项目中?嗯,为每个项目创建单独的图片很诱人。但是,这会导致每张图片都产生大量的网络请求,从而降低性能。相反,我们可以创建一个图片精灵。事实上,我就是这么做的。
然后,为了保持响应式,我们可以在 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
值如何影响场景。移动滑块来查看如何减少运动。
这个演示还展示了另一个功能:可以选择一支“队伍”来改变科迪的颜色。这里巧妙之处在于,只需要指向图像精灵的不同部分即可。
这就是创建 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>
)
}
然后,如果你注意到了,里面有一个名为 的钩子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>
。这很简单。它是一个将 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>
)
}
将所有这些结合在一起,你最终会得到如下结果:
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>
)
这给了我们视差场景!
就是这样!
我们刚刚把一张静态图片变成了一个由 CSS 自定义属性驱动的精美视差场景!这很有意思,因为图像精灵虽然已经存在很久了,但至今仍然用途广泛!
保持精彩!ʕ •ᴥ•ʔ
文章来源:https://dev.to/jh3y/parallax-powered-by-css-custom-properties-3p83