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

Orbiting Circles

Узлы едут по круговым орбитам: угол каждого задаёт отрицательная задержка одной общей анимации, контр-вращение держит их вертикально. Чистый CSS.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
radius?number110Радиус орбиты, px.
duration?number20Секунд на полный оборот.
reverse?booleanfalseКрутить в обратную сторону.
iconSize?number40Размер узла на орбите, px.
ring?booleantrueРисовать ли само кольцо орбиты.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/orbiting-circles.json
Что нужно в проекте до установки
  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

Код

import { Children } from "react";
import type { CSSProperties, ReactNode } from "react";

export interface OrbitingCirclesProps {
  /** Радиус орбиты, px. */
  radius?: number;
  /** Секунд на полный оборот. */
  duration?: number;
  /** Крутить в обратную сторону. */
  reverse?: boolean;
  /** Размер узла на орбите, px. */
  iconSize?: number;
  /** Рисовать ли само кольцо орбиты. */
  ring?: boolean;
  /** Узлы, которые едут по орбите; раскладываются по кругу равномерно. */
  children?: ReactNode;
}

/**
 * Узлы едут по круговой орбите. Хитрость раскладки: у всех узлов ОДНА анимация
 * вращения, а угол каждого задаёт отрицательная animation-delay (−i/n цикла) —
 * ни одного вычисления угла ни в JS, ни в CSS. Контр-вращение с той же задержкой
 * держит узел вертикально. Reduced-motion / data-shot — узлы стоят на кольце.
 */
export function OrbitingCircles({
  radius = 110,
  duration = 20,
  reverse = false,
  iconSize = 40,
  ring = true,
  children,
}: OrbitingCirclesProps = {}) {
  const items = Children.toArray(children);
  const n = Math.max(1, items.length);
  return (
    <div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
      <style>{`
        .oc-spin {
          position: absolute; left: 50%; top: 50%;
          animation: oc-rotate var(--oc-dur) linear infinite;
          animation-delay: var(--oc-delay);
          animation-direction: var(--oc-dir);
        }
        /* контр-вращение — та же длительность и задержка, направление противоположное:
           сумма углов всегда ноль, узел не кувыркается */
        .oc-keep {
          display: grid; place-items: center;
          width: var(--oc-size); height: var(--oc-size);
          margin: calc(var(--oc-size) / -2);
          border-radius: 999px;
          background: oklch(0.21 0.02 270);
          border: 1px solid oklch(0.34 0.02 270);
          animation: oc-rotate var(--oc-dur) linear infinite reverse;
          animation-delay: var(--oc-delay);
          animation-direction: var(--oc-keep-dir);
        }
        /* from задан ЯВНО: пока анимация жива, она полностью владеет углом, а базовый
           transform (инлайн, угол i/n) показывается только при animation: none —
           без него в reduced-motion все узлы встали бы кучей в угол 0 */
        @keyframes oc-rotate { from { transform: rotate(0turn); } to { transform: rotate(1turn); } }
        @media (prefers-reduced-motion: reduce) { .oc-spin, .oc-keep { animation: none; } }
      `}</style>
      {ring && (
        <div
          style={{
            position: "absolute",
            left: "50%",
            top: "50%",
            width: radius * 2,
            height: radius * 2,
            margin: -radius,
            borderRadius: "999px",
            border: "1px dashed oklch(0.34 0.02 270 / 0.7)",
          }}
        />
      )}
      {items.map((child, i) => (
        <div
          key={i}
          className="oc-spin"
          style={
            {
              transform: `rotate(${i / n}turn)`,
              "--oc-dur": `${duration}s`,
              "--oc-delay": `${(-duration * i) / n}s`,
              "--oc-dir": reverse ? "reverse" : "normal",
              "--oc-keep-dir": reverse ? "normal" : "reverse",
              "--oc-size": `${iconSize}px`,
            } as CSSProperties
          }
        >
          {/* узел выносится на орбиту трансформом РОДИТЕЛЯ-вращателя: translate до и после
              rotate складываются в круговую траекторию без единого вычисления угла */}
          <div style={{ transform: `translateX(${radius}px)` }}>
            <div className="oc-keep" style={{ transform: `rotate(${-i / n}turn)` }}>
              {child}
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Orbiting Circles (MIT)v1.0.0