feat(display): pointer-sway background with a dark variant; acrylic glass tokens [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-09 03:09:14 +08:00
1 parent 70f20d38d0
commit f8e8bb8acb
7 files changed
+212 -31

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import { useEffect, type RefObject } from 'react'
/** 指针在窗口里的位置带来的偏移上限(像素):鼠标停在窗口边缘时背景偏这么多。 */
const POSITION_SHIFT = 30
/** 鼠标移动时额外的晃动上限(像素):移得越快晃得越多,停下来就慢慢回到位置对应的偏移。两项加起来不能超过 display.css 里背景层向外多出来的边距(130px)。 */
const SWAY_MAX = 90
/** 每移动 1 像素,晃动增加多少像素。 */
const SWAY_GAIN = 0.8
/** 晃动每一帧衰减到多少:越接近 1,晃得越久。 */
const SWAY_DECAY = 0.94
/** 每一帧追上目标的比例:越小越慢、拖拽感越强。 */
const EASE = 0.05
/**
* 背景跟着鼠标(或手指)移动,带一点拖拽的惯性:
* - 位置:指针在窗口里的位置对应一个小的偏移(最多 POSITION_SHIFT),背景每帧只追上一小段;
* - 晃动:鼠标移动的时候,移动的距离再叠加一份额外的偏移(移得越快越大,最多 SWAY_MAX),停下来之后逐渐衰减,所以移动时背景「晃」得更明显。
* 指针离开窗口,背景慢慢回到中间。直接改背景层自己的 transform(不经过 CSS 变量:变量会让整页的样式重新计算,卡片又多);
* 追上并且晃动衰减完之后就停止动画帧,不空转。用户开了「减少动态效果」就什么都不做。
*/
export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const element = layer.current
if (!element) return
let baseX = 0
let baseY = 0
let swayX = 0
let swayY = 0
let x = 0
let y = 0
let lastX: number | null = null
let lastY: number | null = null
let frame = 0
const step = () => {
swayX *= SWAY_DECAY
swayY *= SWAY_DECAY
const targetX = baseX + swayX
const targetY = baseY + swayY
x += (targetX - x) * EASE
y += (targetY - y) * EASE
element.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`
const moving = Math.abs(targetX - x) > 0.05 || Math.abs(targetY - y) > 0.05 || Math.abs(swayX) > 0.05 || Math.abs(swayY) > 0.05
frame = moving ? requestAnimationFrame(step) : 0
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(step)
}
const clamp = (value: number) => Math.max(-SWAY_MAX, Math.min(SWAY_MAX, value))
const move = (event: PointerEvent) => {
// 位置:指针在窗口里的位置换成 -1 到 1(背景朝同一个方向偏移,想要反方向就把符号取反)
baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT
baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT
// 晃动:这一次移动了多远,就再推背景一把
if (lastX !== null && lastY !== null) {
swayX = clamp(swayX + (event.clientX - lastX) * SWAY_GAIN)
swayY = clamp(swayY + (event.clientY - lastY) * SWAY_GAIN)
}
lastX = event.clientX
lastY = event.clientY
schedule()
}
const leave = () => {
baseX = 0
baseY = 0
lastX = null
lastY = null
schedule()
}
window.addEventListener('pointermove', move, { passive: true })
document.documentElement.addEventListener('mouseleave', leave)
return () => {
window.removeEventListener('pointermove', move)
document.documentElement.removeEventListener('mouseleave', leave)
cancelAnimationFrame(frame)
element.style.transform = ''
}
}, [layer])
}