拟态坞站

dock · neumorphic · 灵感来源:macOS Dock 的 magnification + Neumorphism 的同色浮起

hover 看放大 · 点击弹跳并切换运行状态

复用卡

macOS-style Dock, Neumorphic Tray · 拟态坞站 · 坞站 / Neumorphic

规格 · 术语提示词(给 AI 用)

做一个 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 的同色浮起

参数(CSS 变量)

--color-text --color-text-muted --dur-fast --neuro-bg --neuro-dark --neuro-light --neuro-shadow --text-xs

必缺状态

焦点

整页场景

(尚未接进整页场景)

安装(一条链接)

参考(本站可核对)

调研笔记

拟态坞站

  • 与玻璃、极简坞站同一套 props 与放大曲线(余弦平方衰减、逐帧写尺寸、离场 300ms 补间)
  • 托盘与图标同色浮起(各自一组外双阴影),没有边框、模糊与折射
  • 每个图标在托盘上再浮一层:两级同色阴影叠出"盘上放块"的关系
  • tooltip 也用同色小阴影;运行中圆点与点击弹跳不变

限制:图标阴影与托盘阴影方向相同,靠得很近时会互相吃掉;低对比环境里边界会变弱。

实现

dock/neumorphic
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>
  );
}

元信息

标识
dock/neumorphic
状态
done
最后提交
2026-10-31
风格轴
neumorphic
依赖 token
--color-text --color-text-muted --dur-fast --neuro-bg --neuro-dark --neuro-light --neuro-shadow --text-xs
在工作台打开 源码与 notes 都在 src/components/dock/neumorphic/