dock · neumorphic · 灵感来源:macOS Dock 的 magnification + Neumorphism 的同色浮起
hover 看放大 · 点击弹跳并切换运行状态
macOS-style Dock, Neumorphic Tray · 拟态坞站 · 坞站 / Neumorphic
做一个 macOS-style Dock, Neumorphic Tray(拟态坞站):
- 余弦平方衰减的放大:峰值在鼠标处,半径外平滑归零
- 放大逐帧改尺寸(邻居被推开),移出用 300ms 补间回位
- 托盘与图标各一组同色双阴影(盘上放块)
- 悬停显示标签 tooltip,运行中显示底部圆点
用这些 CSS 变量:--color-text --color-text-muted --dur-fast --neuro-bg --neuro-dark --neuro-light --neuro-shadow --text-xs
灵感参考:macOS Dock 的 magnification + Neumorphism 的同色浮起--color-text --color-text-muted --dur-fast --neuro-bg --neuro-dark --neuro-light --neuro-shadow --text-xs
焦点
(尚未接进整页场景)
/r/dock--neumorphic.json
免外壳的 markdown 版:/llms/components/dock--neumorphic.md限制:图标阴影与托盘阴影方向相同,靠得很近时会互相吃掉;低对比环境里边界会变弱。
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { prefersReducedMotion } from '~/lib/motion';
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;
}
/**
* 拟态坞站:与玻璃坞站同一套放大曲线与 rAF 补间,
* 托盘与图标同色浮起(各一组双阴影),没有边框、模糊与折射。
*/
export function NeumorphicDock({
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), []);
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(--neuro-bg)]
shadow-[var(--neuro-shadow)]
${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(--neuro-bg)]
shadow-[3px_3px_7px_var(--neuro-dark),-3px_-3px_7px_var(--neuro-light)]
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(--neuro-bg)]
shadow-[3px_3px_7px_var(--neuro-dark),-3px_-3px_7px_var(--neuro-light)]
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/neumorphic/