dock · glass-apple · 灵感来源:macOS Dock 的 magnification
hover 看放大 · 点击弹跳并切换运行状态
macOS-style Dock with Magnification · 放大坞站 · 坞站 / Glass
做一个 macOS-style Dock with Magnification(放大坞站):
- 余弦平方衰减的放大:峰值在鼠标处,半径外平滑归零
- 放大逐帧改尺寸(邻居被推开),移出用 300ms 补间回位
- 玻璃托盘 + 边缘折射滤镜,不支持则静默降级
- 悬停显示标签 tooltip
用这些 CSS 变量:--color-surface --color-text --color-text-muted --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --shadow-md --shadow-sm --text-xs
灵感参考:macOS Dock 的 magnification--color-surface --color-text --color-text-muted --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --shadow-md --shadow-sm --text-xs
焦点
(尚未接进整页场景)
/r/dock--glass-apple.json
免外壳的 markdown 版:/llms/components/dock--glass-apple.mdt = cos²(d/r · π/2):d 为鼠标到图标中心距离,r 为影响半径(默认 2.4 × 图标宽);中心 ~100%,第 2 邻 ~40%,第 3 邻 ~5%变量:--glass-bg(surface 28%)/ 弹跳曲线 --ease-settle。
blur + saturate(1.6) + brightness(1.06) 增透(L1)feDisplacementMap 位移滤镜,边缘背景真实弯曲(L3);
技法来源 nikdelvin/liquid-glass(MIT),简化为单通道无色散,支持检测降级 + 120ms 防抖import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { prefersReducedMotion } from '~/lib/motion';
import { applyRefraction } from './refraction';
export interface DockItem {
id: string;
label: string;
icon: ReactNode;
running?: boolean;
onClick?: (id: string) => void;
}
interface Props {
items: DockItem[];
baseSize?: number;
maxSize?: number;
range?: number;
className?: string;
}
export function GlassAppleDock({
items,
baseSize = 52,
maxSize = 88,
range,
className = '',
}: Props) {
const rangePx = range ?? baseSize * 2.4;
const trayRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const hoveringRef = useRef(false);
const [bouncingId, setBouncingId] = useState<string | null>(null);
const icons = useCallback(() => {
const tray = trayRef.current;
if (!tray) return [] as HTMLButtonElement[];
return Array.from(tray.querySelectorAll<HTMLButtonElement>('[data-dock-icon]'));
}, []);
/** 尺寸写入:放大是逐帧改尺寸(macOS 式把邻居推开),不是 CSS 过渡——
过渡动 width/height 会触发布局重算,还会被逐帧写入顶掉。 */
const writeSizes = useCallback((sizes: number[]) => {
icons().forEach((b, i) => {
const size = sizes[i];
if (size == null) return;
b.style.width = `${size}px`;
b.style.height = `${size}px`;
const glyph = b.querySelector<HTMLElement>('[data-dock-glyph]');
if (glyph) glyph.style.fontSize = `${size * 0.52}px`;
});
}, [icons]);
// 余弦平方衰减:t = cos²(d/r · π/2),峰在鼠标处,r 处平滑归零
const applySizes = useCallback((mouseX: number | null) => {
const buttons = icons();
if (buttons.length === 0) return;
const centers = buttons.map((b) => {
const r = b.getBoundingClientRect();
return r.left + r.width / 2;
});
const sizes = buttons.map((_, i) => {
let t = 0;
if (mouseX !== null) {
const d = Math.abs(mouseX - centers[i]);
if (d < rangePx) t = Math.cos((d / rangePx) * (Math.PI / 2)) ** 2;
}
return baseSize + (maxSize - baseSize) * t;
});
writeSizes(sizes);
}, [baseSize, maxSize, rangePx, icons, writeSizes]);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
// L3 边缘折射:玻璃托盘的 Liquid Glass 签名效果(不支持时静默降级)
useEffect(() => {
const tray = trayRef.current;
if (!tray) return;
return applyRefraction(tray, { depth: 10, strength: 70 });
}, []);
const onMouseMove = (e: React.MouseEvent) => {
if (prefersReducedMotion()) return; // 降级:不做放大位移
const x = e.clientX;
hoveringRef.current = true;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => applySizes(x));
};
/** 移出:JS 补间回位(--dur-slow),所以 CSS 里不需要动 width/height 的过渡 */
const onMouseLeave = () => {
hoveringRef.current = false;
cancelAnimationFrame(rafRef.current);
const from = icons().map((b) => b.getBoundingClientRect().width);
if (from.length === 0) return;
const start = performance.now();
const step = (now: number) => {
const p = Math.min(1, (now - start) / 300);
const e = 1 - (1 - p) ** 3; // 三次缓出
writeSizes(from.map((s) => s + (baseSize - s) * e));
if (p < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
};
const onClickItem = (item: DockItem) => {
item.onClick?.(item.id);
setBouncingId(item.id);
window.setTimeout(
() => setBouncingId((cur) => (cur === item.id ? null : cur)),
780, // = --seq-dock
);
};
return (
<div
ref={trayRef}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
className={`
relative flex items-end gap-2
px-3 pb-2.5 pt-3
rounded-2xl
bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl
shadow-[var(--shadow-md),var(--glass-inset)]
${className}
`}
>
{items.map((item) => (
<button
key={item.id}
type="button"
data-dock-icon
onClick={() => onClickItem(item)}
aria-label={item.label}
style={{ width: baseSize, height: baseSize }}
className={`
group relative flex items-center justify-center
rounded-[22%]
bg-[var(--glass-highlight)] border border-[var(--glass-border)]
shadow-[var(--shadow-sm)]
cursor-pointer select-none
${bouncingId === item.id ? 'dock-bounce' : ''}
`}
>
<span
data-dock-glyph
className="leading-none"
style={{ fontSize: baseSize * 0.52 }}
>
{item.icon}
</span>
{/* tooltip:hover 显示标签 */}
<span
className="
pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
px-2.5 py-1 rounded-lg whitespace-nowrap
text-[length:var(--text-xs)] text-[var(--color-text)]
bg-[var(--color-surface)] border border-[var(--glass-border)]
shadow-[var(--shadow-sm)]
opacity-0 translate-y-1
transition-[opacity,translate] duration-[var(--dur-fast)]
group-hover:opacity-100 group-hover:translate-y-0
"
>
{item.label}
</span>
{/* 运行中指示点 */}
{item.running && (
<span
className="
absolute -bottom-[7px] left-1/2 -translate-x-1/2
size-1 rounded-full bg-[var(--color-text-muted)]
"
/>
)}
</button>
))}
</div>
);
}
src/components/dock/glass-apple/