极简坞站

dock · minimal · 灵感来源:macOS Dock 的 magnification + Refactoring UI 的平面托盘

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

复用卡

macOS-style Dock, Flat Tray · 极简坞站 · 坞站 / Minimal

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

做一个 macOS-style Dock, Flat Tray(极简坞站):
- 余弦平方衰减的放大:峰值在鼠标处,半径外平滑归零
- 放大逐帧改尺寸(邻居被推开),移出用 300ms 补间回位
- 白底 + 发丝边托盘,无模糊无折射
- 悬停显示标签 tooltip,运行中显示底部圆点
用这些 CSS 变量:--color-divider --color-surface --color-text --color-text-muted --dur-fast --shadow-sm --text-xs
灵感参考:macOS Dock 的 magnification + Refactoring UI 的平面托盘

参数(CSS 变量)

--color-divider --color-surface --color-text --color-text-muted --dur-fast --shadow-sm --text-xs

必缺状态

焦点

整页场景

(尚未接进整页场景)

安装(一条链接)

参考(本站可核对)

调研笔记

极简坞站

  • 与玻璃坞站同一套 props 与放大曲线(余弦平方衰减、逐帧写尺寸、离场 300ms 补间)
  • 托盘与图标换成白底 + --color-divider 发丝边,圆角收到 rounded-lg / rounded-md
  • 去掉边缘折射与模糊:没有 L3 光学层,托盘不随背后内容变化
  • tooltip 与运行中圆点行为不变;点击弹跳沿用 .dock-bounce 与 --seq-dock

对比玻璃坞站:少了 applyRefraction 与 backdrop-blur,放大曲线的体感保留,材质换成一块平托盘。

实现

dock/minimal
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 MinimalDock({
  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-lg
        bg-[var(--color-surface)] border border-[var(--color-divider)]
        shadow-[var(--shadow-sm)]
        ${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-md
            bg-[var(--color-surface)] border border-[var(--color-divider)]
            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-md whitespace-nowrap
              text-[length:var(--text-xs)] text-[var(--color-text)]
              bg-[var(--color-surface)] border border-[var(--color-divider)]
              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>
  );
}

元信息

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