Motiva
Мои коллекции
← каталог

Dock Nav

Навигация-док в стиле macOS: раздуваются пружиной только иконки — панель неподвижна, соседи расходятся, у активной всплывает подпись.

motionmouse-movehoverspringmagneticintermediate~18 КБreduced-motion ✓

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
apps?DockApp[]APPS
DockApp — что внутри
  • icon: string
  • label: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/dock-nav.json

зависимости: motion@^12.42.2

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { motion, useMotionValue, useSpring, useTransform, type MotionValue } from "motion/react";
import { useRef } from "react";

export interface DockApp {
  icon: string;
  label: string;
}

const APPS: DockApp[] = [
  { icon: "🎨", label: "Studio" },
  { icon: "📷", label: "Shots" },
  { icon: "🎧", label: "Waves" },
  { icon: "💬", label: "Chat" },
  { icon: "🗂️", label: "Files" },
  { icon: "🧭", label: "Explore" },
  { icon: "⚙️", label: "Settings" },
];

/** Геометрия дока. Размер панели считается из БАЗОВЫХ значений и больше не меняется никогда. */
const BASE = 44; // сторона иконки в покое
const PEAK = 82; // сторона иконки под курсором
const GAP = 10; // зазор между слотами
const PAD_X = 24; // боковые поля = запас, в который «расходятся» раздутые иконки
const PAD_Y = 10;
const RADIUS = 110; // радиус влияния курсора, px
const PITCH = BASE + GAP;

/** Насколько раздута иконка на расстоянии d от курсора: 1 в центре, гладко к 0 на границе радиуса. */
function bump(d: number): number {
  const t = Math.min(1, Math.abs(d) / RADIUS);
  return (Math.cos(Math.PI * t) + 1) / 2;
}

interface Geometry {
  scales: number[];
  offsets: number[];
}

/**
 * Линза дока: по позиции курсора считает масштаб каждой иконки и её горизонтальный сдвиг.
 * Сдвиг = ровно та добавка, которой не хватает соседям, чтобы не наехать друг на друга;
 * ряд расходится симметрично от курсора и целиком помещается в боковые поля панели —
 * поэтому САМА панель неподвижна (как в macOS), двигаются только иконки.
 */
function lens(mouseX: number, count: number): Geometry {
  const scales: number[] = [];
  let nearest = 0;
  let best = Infinity;
  for (let i = 0; i < count; i++) {
    const center = i * PITCH + BASE / 2;
    const d = mouseX - center;
    scales[i] = 1 + (PEAK / BASE - 1) * (Number.isFinite(mouseX) ? bump(d) : 0);
    if (Math.abs(d) < best) {
      best = Math.abs(d);
      nearest = i;
    }
  }

  const offsets = new Array<number>(count).fill(0);
  if (!Number.isFinite(mouseX)) return { scales, offsets };

  // сколько пикселей не хватает между парой соседей, когда оба раздуты
  const grow = (i: number) => (BASE * ((scales[i] ?? 1) - 1)) / 2;
  const need = (i: number) => Math.max(0, grow(i) + grow(i + 1) - GAP);

  let acc = 0;
  for (let i = nearest + 1; i < count; i++) offsets[i] = acc += need(i - 1);
  acc = 0;
  for (let i = nearest - 1; i >= 0; i--) offsets[i] = -(acc += need(i));

  // центрируем расширение, чтобы ряд не уползал в одну сторону
  const shift = ((offsets[count - 1] ?? 0) + (offsets[0] ?? 0)) / 2;
  for (let i = 0; i < count; i++) offsets[i] = (offsets[i] ?? 0) - shift;
  return { scales, offsets };
}

const SPRING = { mass: 0.1, stiffness: 210, damping: 14 } as const;

function DockIcon({
  geometry,
  index,
  app,
  frozen,
}: {
  geometry: MotionValue<Geometry>;
  index: number;
  app: DockApp;
  frozen: boolean;
}) {
  const scale = useSpring(
    useTransform(geometry, (g) => g.scales[index] ?? 1),
    SPRING,
  );
  const x = useSpring(
    useTransform(geometry, (g) => g.offsets[index] ?? 0),
    SPRING,
  );
  // подпись висит НАД иконкой и не масштабируется вместе с ней
  const labelBottom = useTransform(scale, (s) => BASE * s + 10);
  const labelOpacity = useTransform(scale, [1.55, 1.75], [0, 1]); // подпись только у иконки под курсором
  const z = useTransform(scale, (s) => Math.round(s * 100));

  return (
    <div style={{ position: "relative", width: BASE, height: BASE, flexShrink: 0 }}>
      <motion.span
        aria-hidden
        style={{
          position: "absolute",
          bottom: frozen ? BASE + 10 : labelBottom,
          left: "50%",
          translateX: "-50%",
          opacity: frozen ? 0 : labelOpacity,
          padding: "3px 8px",
          borderRadius: 8,
          whiteSpace: "nowrap",
          fontSize: 12,
          fontFamily: "system-ui, sans-serif",
          color: "oklch(0.96 0.01 270)",
          background: "oklch(0.18 0.02 270 / 0.9)",
          border: "1px solid oklch(0.34 0.02 270)",
          pointerEvents: "none",
        }}
      >
        {app.label}
      </motion.span>
      <motion.button
        title={app.label}
        aria-label={app.label}
        style={{
          position: "absolute",
          bottom: 0,
          left: "50%",
          marginLeft: -BASE / 2,
          width: BASE,
          height: BASE,
          x: frozen ? 0 : x,
          scale: frozen ? 1 : scale,
          zIndex: frozen ? 1 : z,
          transformOrigin: "bottom center",
          display: "grid",
          placeItems: "center",
          fontSize: Math.round(BASE * 0.52),
          border: "1px solid oklch(0.34 0.02 270)",
          borderRadius: "24%",
          background: "linear-gradient(145deg, oklch(0.32 0.03 275), oklch(0.22 0.02 270))",
          cursor: "pointer",
          userSelect: "none",
          padding: 0,
          lineHeight: 1,
        }}
      >
        {app.icon}
      </motion.button>
    </div>
  );
}

/**
 * Навигация-док в стиле macOS: пружиной раздуваются ТОЛЬКО иконки, сама панель неподвижна.
 * Иконки растут вверх из фиксированной базовой линии и расходятся в боковые поля панели.
 * Всё на transform (scale/x) — ни одного чтения layout за кадр.
 */
export function DockNav({ apps = APPS }: { apps?: DockApp[] }) {
  const navRef = useRef<HTMLElement>(null);
  const originX = useRef(0); // левый край ряда слотов в координатах вьюпорта (кэш)
  const mouseX = useMotionValue(Infinity);
  const geometry = useTransform(mouseX, (x) => lens(x, apps.length));
  const frozen =
    typeof document !== "undefined" &&
    (document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches);

  const width = apps.length * BASE + (apps.length - 1) * GAP + PAD_X * 2;

  const cacheOrigin = () => {
    const r = navRef.current?.getBoundingClientRect();
    if (r) originX.current = r.left + PAD_X;
  };

  return (
    <nav
      ref={navRef}
      aria-label="Dock"
      onPointerEnter={cacheOrigin}
      onPointerMove={(e) => !frozen && mouseX.set(e.clientX - originX.current)}
      onPointerLeave={() => mouseX.set(Infinity)}
      style={{
        // размеры считаются один раз из базовых метрик → панель не «дышит» под курсором
        position: "relative",
        boxSizing: "content-box",
        display: "flex",
        alignItems: "flex-end",
        gap: GAP,
        width,
        height: BASE,
        padding: `${PAD_Y}px 0`,
        justifyContent: "center",
        borderRadius: 22,
        border: "1px solid oklch(0.32 0.02 270)",
        background: "oklch(0.2 0.02 270 / 0.75)",
        backdropFilter: "blur(14px)",
      }}
    >
      {apps.map((app, i) => (
        <DockIcon key={app.label} geometry={geometry} index={i} app={app} frozen={frozen} />
      ))}
    </nav>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); паттерн — macOS Dockv1.1.0