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

Radial Menu

Радиальное меню: пункты разлетаются веером из центральной кнопки и складываются той же пружиной. Раскладка — чистый CSS: rotate(a) translateY(−R) rotate(−a), контрповорот держит значки вертикально; волна — transition-delay. JS только про состояние и вежливость: aria-expanded, Esc с возвратом фокуса, клик мимо, закрытые пункты сняты из Tab.

cssclickspringintermediatereduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
items?RadialItem[]ITEMS
radius?number96Радиус веера, px.
arcDeg?number360Дуга веера в градусах (360 — полный круг).
aimDeg?number0Куда смотрит середина дуги, градусы (0 — вверх).
RadialItem — что внутри
  • glyph: string— Значок пункта (символ/эмодзи).
  • label: string— Подпись, всплывает при наведении.
  • href?: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/radial-menu.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — GSAP demo «Radial Menu» (код не заимствован)v1.0.0