← каталог
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
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
/**
* Dock Nav — vanilla JS, ноль зависимостей. Док в стиле macOS: раздуваются ТОЛЬКО иконки,
* сама панель неподвижна (её размер считается один раз из базовых метрик).
* Плавность — rAF-lerp; луп спит в покое; всё на transform (ни одного чтения layout за кадр).
*
* Разметка: обычный ряд кнопок, раскладку init берёт на себя (абсолютное позиционирование).
*
* <nav id="dock">
* <button data-label="Studio">🎨</button>
* <button data-label="Shots">📷</button>
* <button data-label="Waves">🎧</button>
* </nav>
* <script type="module">
* import { initDockNav } from './dock-nav.vanilla.js';
* const destroy = initDockNav(document.querySelector('#dock'), { base: 44, peak: 82 });
* </script>
*
* @param {HTMLElement} nav — контейнер; магнифицируются его дети
* @param {{ itemSelector?: string, base?: number, peak?: number, gap?: number, padX?: number,
* padY?: number, radius?: number, labels?: boolean }} [options]
* @returns {() => void} destroy
*/
export function initDockNav(
nav,
{
itemSelector = ":scope > *",
base = 44,
peak = 82,
gap = 10,
padX = 24,
padY = 10,
radius = 110,
labels = true,
} = {},
) {
const items = [...nav.querySelectorAll(itemSelector)];
if (items.length === 0) return () => {};
const pitch = base + gap;
const width = items.length * base + (items.length - 1) * gap + padX * 2;
const height = base + padY * 2;
// Панель: фиксированные размеры раз и навсегда — под курсором она не меняется.
nav.style.position = "relative";
nav.style.boxSizing = "border-box";
nav.style.width = `${width}px`;
nav.style.height = `${height}px`;
const labelEls = [];
items.forEach((it, i) => {
it.style.position = "absolute";
it.style.bottom = `${padY}px`;
it.style.left = `${padX + i * pitch}px`;
it.style.width = `${base}px`;
it.style.height = `${base}px`;
it.style.fontSize = `${Math.round(base * 0.52)}px`;
it.style.transformOrigin = "bottom center";
it.style.willChange = "transform";
if (!labels) return;
const text = it.dataset.label || it.title;
if (!text) return;
const el = document.createElement("span");
el.textContent = text;
el.setAttribute("aria-hidden", "true");
el.style.cssText = `position:absolute;left:${padX + i * pitch + base / 2}px;bottom:${padY + base + 10}px;
transform:translateX(-50%);opacity:0;padding:3px 8px;border-radius:8px;white-space:nowrap;
font:12px 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);pointer-events:none;`;
nav.appendChild(el);
labelEls[i] = el;
});
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return () => {};
const scales = items.map(() => 1);
const offsets = items.map(() => 0);
let mouseX = Infinity; // координата курсора внутри ряда слотов
let originX = 0;
let raf = 0;
let running = false;
/** Целевые масштаб + сдвиг: сдвиг ровно такой, чтобы раздутые соседи не наехали друг на друга. */
const target = () => {
const s = [];
let nearest = 0;
let best = Infinity;
for (let i = 0; i < items.length; i++) {
const d = mouseX - (i * pitch + base / 2);
const t = Math.min(1, Math.abs(d) / radius);
s[i] = 1 + (peak / base - 1) * (mouseX === Infinity ? 0 : (Math.cos(Math.PI * t) + 1) / 2);
if (Math.abs(d) < best) {
best = Math.abs(d);
nearest = i;
}
}
const o = items.map(() => 0);
if (mouseX === Infinity) return { s, o };
const need = (i) => Math.max(0, (base * (s[i] - 1)) / 2 + (base * (s[i + 1] - 1)) / 2 - gap);
let acc = 0;
for (let i = nearest + 1; i < items.length; i++) o[i] = acc += need(i - 1);
acc = 0;
for (let i = nearest - 1; i >= 0; i--) o[i] = -(acc += need(i));
const shift = (o[items.length - 1] + o[0]) / 2;
for (let i = 0; i < items.length; i++) o[i] -= shift;
return { s, o };
};
const tick = () => {
const { s, o } = target();
let busy = false;
for (let i = 0; i < items.length; i++) {
scales[i] += (s[i] - scales[i]) * 0.22;
offsets[i] += (o[i] - offsets[i]) * 0.22;
if (Math.abs(s[i] - scales[i]) > 0.004 || Math.abs(o[i] - offsets[i]) > 0.3) busy = true;
const sc = Math.round(scales[i] * 1000) / 1000;
items[i].style.transform = `translateX(${offsets[i].toFixed(1)}px) scale(${sc})`;
items[i].style.zIndex = String(Math.round(sc * 100));
const label = labelEls[i];
if (label) {
label.style.opacity = String(Math.min(1, Math.max(0, (sc - 1.55) / 0.2)));
label.style.transform = `translateX(calc(-50% + ${offsets[i].toFixed(1)}px)) translateY(${(
-base * (sc - 1)
).toFixed(1)}px)`;
}
}
if (!busy) {
running = false; // всё доехало — спим до следующего pointermove
return;
}
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (running) return;
running = true;
raf = requestAnimationFrame(tick);
};
const cacheOrigin = () => {
originX = nav.getBoundingClientRect().left + padX;
};
const onEnter = () => cacheOrigin();
const onMove = (e) => {
mouseX = e.clientX - originX;
wake();
};
const onLeave = () => {
mouseX = Infinity;
wake();
};
cacheOrigin();
nav.addEventListener("pointerenter", onEnter);
nav.addEventListener("pointermove", onMove);
nav.addEventListener("pointerleave", onLeave);
window.addEventListener("resize", cacheOrigin);
return () => {
cancelAnimationFrame(raf);
nav.removeEventListener("pointerenter", onEnter);
nav.removeEventListener("pointermove", onMove);
nav.removeEventListener("pointerleave", onLeave);
window.removeEventListener("resize", cacheOrigin);
labelEls.forEach((el) => el?.remove());
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); паттерн — macOS Dockv1.1.0