Radial Menu
Радиальное меню: пункты разлетаются веером из центральной кнопки и складываются той же пружиной. Раскладка — чистый CSS: rotate(a) translateY(−R) rotate(−a), контрповорот держит значки вертикально; волна — transition-delay. JS только про состояние и вежливость: aria-expanded, Esc с возвратом фокуса, клик мимо, закрытые пункты сняты из Tab.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | RadialItem[] | ITEMS | |
| radius? | number | 96 | Радиус веера, px. |
| arcDeg? | number | 360 | Дуга веера в градусах (360 — полный круг). |
| aimDeg? | number | 0 | Куда смотрит середина дуги, градусы (0 — вверх). |
RadialItem — что внутри
- glyph: string— Значок пункта (символ/эмодзи).
- label: string— Подпись, всплывает при наведении.
- href?: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/radial-menu.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
const css = `.rdm {
position: relative;
display: grid;
place-items: center;
/* рамка держит место под раскрытый веер: открытие ничего вокруг не двигает */
width: calc(var(--rdm-r) * 2 + 96px);
aspect-ratio: 1;
font-family: var(--ds-font, system-ui, sans-serif);
}
.rdm-fab {
position: relative;
z-index: 2;
display: grid;
place-items: center;
width: 64px;
aspect-ratio: 1;
border-radius: 50%;
border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
background: var(--ds-accent, oklch(0.72 0.16 300));
color: var(--ds-accent-fg, oklch(0.99 0.004 265));
font-size: 1.35rem;
cursor: pointer;
transition: rotate var(--ds-base, 0.32s) var(--ds-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
}
.rdm[data-open] .rdm-fab { rotate: 45deg; }
.rdm-fab:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300)); outline-offset: 3px; }
.rdm-ring { margin: 0; padding: 0; list-style: none; }
/* пункт стоит В ЦЕНТРЕ и уезжает на радиус своим углом: rotate(a) → translate(R)
→ rotate(−a) — контрповорот держит иконку вертикально при любом угле рычага */
.rdm-item {
position: absolute;
left: 50%;
top: 50%;
z-index: 1;
display: grid;
place-items: center;
width: 48px;
aspect-ratio: 1;
margin: -24px 0 0 -24px;
border-radius: 50%;
border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
background: var(--ds-surface, oklch(0.18 0.02 275));
color: var(--ds-fg, oklch(0.95 0.01 265));
text-decoration: none;
font-size: 1.05rem;
/* закрыто: в центре, невидим и НЕДОСТИЖИМ (visibility снимает пункт и из Tab) */
visibility: hidden;
opacity: 0;
transform: rotate(var(--rdm-a)) translateY(0) rotate(calc(var(--rdm-a) * -1)) scale(0.4);
transition:
transform var(--ds-slow, 0.55s) var(--ds-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
opacity var(--ds-fast, 0.18s) linear,
visibility 0s var(--ds-fast, 0.18s);
/* волна: наружу — по порядку пунктов, внутрь — той же лесенкой */
transition-delay: var(--rdm-delay, 0s), var(--rdm-delay, 0s), calc(var(--rdm-delay, 0s) + var(--ds-fast, 0.18s));
}
.rdm[data-open] .rdm-item {
visibility: visible;
opacity: 1;
transform: rotate(var(--rdm-a)) translateY(calc(var(--rdm-r) * -1)) rotate(calc(var(--rdm-a) * -1)) scale(1);
transition-delay: var(--rdm-delay, 0s), var(--rdm-delay, 0s), 0s;
}
.rdm-item:hover, .rdm-item:focus-visible {
border-color: var(--ds-accent, oklch(0.72 0.16 300));
color: var(--ds-accent, oklch(0.72 0.16 300));
}
.rdm-item:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300)); outline-offset: 2px; }
/* подпись пункта — всплывает под кружком */
.rdm-item > small {
position: absolute;
top: calc(100% + 5px);
font: 500 0.62rem/1 var(--ds-font-mono, ui-monospace, monospace);
letter-spacing: 0.06em;
color: var(--ds-muted, oklch(0.72 0.02 265));
opacity: 0;
transition: opacity var(--ds-fast, 0.18s);
pointer-events: none;
white-space: nowrap;
}
.rdm-item:hover > small, .rdm-item:focus-visible > small { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.rdm-fab, .rdm-item, .rdm-item > small { transition: none; }
}
html[data-shot] .rdm-fab, html[data-shot] .rdm-item { transition: none; }
`;
export interface RadialItem {
/** Значок пункта (символ/эмодзи). */
glyph: string;
/** Подпись, всплывает при наведении. */
label: string;
href?: string;
}
export interface RadialMenuProps {
items?: RadialItem[];
/** Радиус веера, px. */
radius?: number;
/** Дуга веера в градусах (360 — полный круг). */
arcDeg?: number;
/** Куда смотрит середина дуги, градусы (0 — вверх). */
aimDeg?: number;
}
const ITEMS: RadialItem[] = [
{ glyph: "✎", label: "создать" },
{ glyph: "⌘", label: "команды" },
{ glyph: "✦", label: "избранное" },
{ glyph: "↺", label: "история" },
{ glyph: "⚙", label: "настройки" },
];
/**
* Радиальное меню: пункты разлетаются веером из центральной кнопки и складываются
* обратно той же пружиной. Раскладка — чистый CSS: пункт стоит в центре и уезжает
* на радиус цепочкой `rotate(a) translateY(−R) rotate(−a)` — контрповорот держит
* значок вертикально при любом угле (та же математика, что у кабинок photo-ring).
* Волна разлёта — `transition-delay` по номеру пункта, ноль скриптов на движение.
*
* JS занимается только СОСТОЯНИЕМ и вежливостью: `aria-expanded`, закрытие по Esc
* (с возвратом фокуса на кнопку) и по клику мимо; закрытые пункты сняты и из Tab —
* `visibility: hidden` с задержкой на время сложения. Рамка компонента заранее
* держит место под раскрытый веер, поэтому открытие ничего вокруг не двигает.
*/
export function RadialMenu({ items = ITEMS, radius = 96, arcDeg = 360, aimDeg = 0 }: RadialMenuProps = {}) {
const host = useRef<HTMLDivElement>(null);
const fab = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
fab.current?.focus();
}
};
const onDown = (e: PointerEvent) => {
if (!host.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown);
};
}, [open]);
const n = items.length;
const full = arcDeg >= 360;
// полный круг делится на n без шва; дуга — на n−1, чтобы края стояли на краях
const step = full ? arcDeg / n : arcDeg / Math.max(1, n - 1);
const start = aimDeg - (full ? 0 : arcDeg / 2);
return (
<div ref={host} className="rdm" data-open={open ? "" : undefined} style={{ "--rdm-r": `${radius}px` } as CSSProperties}>
<style href="motiva-radial-menu" precedence="medium">{css}</style>
<button
ref={fab}
type="button"
className="rdm-fab"
aria-expanded={open}
aria-label={open ? "Закрыть меню" : "Открыть меню"}
onClick={() => setOpen((o) => !o)}
>
+
</button>
<ul className="rdm-ring">
{items.map((it, i) => (
<li key={it.label}>
<a
className="rdm-item"
href={it.href ?? "#"}
style={{ "--rdm-a": `${start + i * step}deg`, "--rdm-delay": `${i * 40}ms` } as CSSProperties}
tabIndex={open ? 0 : -1}
>
<span aria-hidden="true">{it.glyph}</span>
<small>{it.label}</small>
<span className="sr-only" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
{it.label}
</span>
</a>
</li>
))}
</ul>
</div>
);
}