程序坞放大
指针靠近时程序坞里的图标逐渐变大,离开时又缩回去,像桌面程序坞一样把相邻图标推开。
用 React 制作会放大的程序坞。图标按钮在 flex 行中底部对齐排列。指针在程序坞上 pointermove 时,对每个图标计算指针到图标中心的水平距离,缩放比为 1 + 0.7 × max(0, 1 − 距离 / 110),并写入图标的 CSS 自定义属性 --s(不使用 React state)。用 width 和 height calc(40px × var(--s)) 控制尺寸而不是 transform,这样变大的图标会把相邻图标推开;width 和 height 过渡 120ms ease-out。pointerleave 时把所有 --s 重置为 1。每个按钮带 aria-label,悬停或聚焦时在上方显示名称小标签。在 prefers-reduced-motion 下图标保持固定尺寸。应用为邮件、备忘录、音乐、照片、地图、文件。
源代码
'use client'
import { useRef } from 'react'
import s from './demo.module.css'
const REACH = 110
export default function DockMagnify({ labels }: { labels: Record<string, string> }) {
const dock = useRef<HTMLDivElement>(null)
const items = labels.items.split('|')
const onMove = (e: React.PointerEvent) => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
for (const el of Array.from(dock.current?.children ?? []) as HTMLElement[]) {
const r = el.getBoundingClientRect()
const d = Math.abs(e.clientX - (r.left + r.width / 2))
el.style.setProperty('--s', String(1 + 0.7 * Math.max(0, 1 - d / REACH)))
}
}
const onLeave = () => {
for (const el of Array.from(dock.current?.children ?? []) as HTMLElement[]) el.style.setProperty('--s', '1')
}
return (
<div ref={dock} className={s.dock} role="toolbar" aria-label={labels.dock} onPointerMove={onMove} onPointerLeave={onLeave}>
{items.map((name, i) => (
<button key={name} type="button" className={s.icon} aria-label={name} style={{ '--h': i * 55 } as React.CSSProperties}>
<span className={s.tip} aria-hidden="true">
{name}
</span>
{Array.from(name)[0]}
</button>
))}
</div>
)
}
.dock {
display: flex;
align-items: flex-end;
gap: 8px;
height: 96px;
padding: 8px 10px;
border: 1px solid var(--stage-rule);
border-radius: 18px;
background: var(--stage-surface);
align-self: end;
margin-bottom: 24px;
}
.icon {
--s: 1;
position: relative;
display: grid;
place-items: center;
width: calc(40px * var(--s));
height: calc(40px * var(--s));
padding: 0;
border: 0;
border-radius: calc(10px * var(--s));
background: oklch(62% 0.11 calc(40 + var(--h)));
color: oklch(20% 0.02 60);
font: 700 calc(var(--text-base) * var(--s)) / 1 var(--font-display);
cursor: pointer;
transition: width 120ms var(--ease-out), height 120ms var(--ease-out), border-radius 120ms var(--ease-out), font-size 120ms var(--ease-out);
}
.icon:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
translate: -50% 0;
padding: 4px 7px;
border-radius: 6px;
background: var(--stage-ink);
color: var(--stage-bg);
font: 500 var(--text-xs) / 1 var(--font-body);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 120ms;
}
.icon:hover .tip,
.icon:focus-visible .tip { opacity: 1; }
@media (max-width: 380px) {
.dock { gap: 5px; }
.icon { width: calc(34px * var(--s)); height: calc(34px * var(--s)); }
}
@media (prefers-reduced-motion: reduce) {
.icon { transition: none; }
}