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

Orbiting Circles

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

cssautobasicreduced-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 в корне

    Из него 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

Код

const css = `.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; } }
`;
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 href="motiva-orbiting-circles" precedence="medium">{css}</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)v1.0.0