anima.js
ギャラリー
レイアウト

OrbitingIcons

CSS

「連携サービス」ヒーロー用の軌道ビジュアル — 中央のロゴの周りを、ガラスのアイコンチップが同心円の軌道に沿って回ります。アイコンは常に正立、ときどき中央へ光のビームが走る。純粋な CSS アニメーション。

  • デザイン
  • カラー
  • コード
  • ターミナル
  • データベース
  • クラウド
  • 自動化
  • Web
  • 認証
  • 分析
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約30 KB)。
# OrbitingIcons をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: OrbitingIcons
- 説明: 「連携サービス」ヒーロー用の軌道ビジュアル — 中央のロゴの周りを、ガラスのアイコンチップが同心円の軌道に沿って回ります。アイコンは常に正立、ときどき中央へ光のビームが走る。純粋な CSS アニメーション。
- 実行時に必要な npm パッケージ: lucide-react
- 構成ファイル: `components/orbiting-icons/index.tsx`, `components/orbiting-icons/OrbitingIcons.module.css`

## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。

## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install lucide-react
```

### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。

#### `components/orbiting-icons/index.tsx`
```tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ComponentType,
  type CSSProperties,
  type ReactNode,
} from "react";
import styles from "./OrbitingIcons.module.css";

/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;

/**
 * Any icon component that takes a className — lucide-react icons fit, as do
 * most icon libraries. Typed structurally so the component itself has no
 * runtime dependency on an icon package.
 */
export type OrbitIconComponent = ComponentType<{
  className?: string;
  strokeWidth?: number;
  "aria-hidden"?: boolean | "true" | "false";
}>;

export type OrbitItem = {
  /** Name of the integration — read by assistive tech and shown as a tooltip. */
  label: string;
  /** Icon drawn inside the chip. */
  icon?: OrbitIconComponent;
  /** Custom chip content (a logo image, initials…). Wins over `icon`. */
  node?: ReactNode;
};

export type OrbitRing = {
  /**
   * Orbit radius. ≤ 1 is a fraction of `size` (0.5 = touches the edge);
   * above 1 it is px at the full `size`, scaled with the rest.
   */
  radius: number;
  /** Seconds per revolution. */
  speed: number;
  /** Orbit counter-clockwise. */
  reverse?: boolean;
  items: OrbitItem[];
};

export type OrbitRingStyle = "dashed" | "solid" | "gradient";

export type OrbitingIconsProps = {
  /** 1–3 concentric orbits, innermost first. */
  rings: OrbitRing[];
  /**
   * Width (= height) in px at which the layout is designed. The component
   * shrinks to its container below that, keeping every proportion.
   */
  size?: number;
  /** Diameter of the center disc in px at the full `size`. */
  centerSize?: number;
  /** Diameter of each icon chip in px at the full `size`. */
  chipSize?: number;
  /** Draw the orbit lines. */
  showRings?: boolean;
  /** "gradient" = a lit comet arc that travels with the ring. */
  ringStyle?: OrbitRingStyle;
  /** Occasional light pulses running from a chip toward the center. */
  showBeams?: boolean;
  /** Freeze everything while the pointer is over the component. */
  pauseOnHover?: boolean;
  /** Center glow, beam and gradient-arc color. */
  glowColor?: string;
  /** Center content — typically your logo. Sized to ~42% of the disc if it is an icon. */
  children?: ReactNode;
  className?: string;
  /** Names the whole visual (rendered as a labelled group). */
  "aria-label"?: string;
};

const DEFAULT_SIZE = 420;
const DEFAULT_CENTER_SIZE = 104;
const DEFAULT_CHIP_SIZE = 44;
const DEFAULT_GLOW = "#8b7bff";
/** The default size ratios, mirrored in the stylesheet's root defaults. */
const DEFAULT_CENTER_RATIO = DEFAULT_CENTER_SIZE / DEFAULT_SIZE;
const DEFAULT_CHIP_RATIO = DEFAULT_CHIP_SIZE / DEFAULT_SIZE;
/** Beam cycle in seconds; each pulse occupies the first ~22% of it. */
const BEAM_CYCLE = 7;
/** Floor for a revolution, so a 0 / negative speed can't produce a frozen or invalid animation. */
const MIN_SPEED = 2;

const ratio = (n: number) => Number(n.toFixed(5));

/**
 * "Integrations" hero visual: a center disc with concentric orbits along
 * which icon chips travel. Pure CSS — each ring wrapper rotates, and each
 * chip runs the same animation backwards, so the two cancel out and the
 * icons stay upright. The angular spread lives on static "spokes", which
 * means reduced motion (animations off) still leaves an evenly
 * distributed, upright layout.
 *
 * Every length is a fraction of the root's width in `cqi`, so the whole
 * thing scales to fit its container like an image without measuring.
 */
export function OrbitingIcons({
  rings,
  size = DEFAULT_SIZE,
  centerSize = DEFAULT_CENTER_SIZE,
  chipSize = DEFAULT_CHIP_SIZE,
  showRings = true,
  ringStyle = "gradient",
  showBeams = true,
  pauseOnHover = true,
  glowColor = DEFAULT_GLOW,
  children,
  className,
  "aria-label": ariaLabel,
}: OrbitingIconsProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const [offscreen, setOffscreen] = useState(false);

  // Stop the orbits while scrolled out of view — compositor-only, but not free.
  useEffect(() => {
    const root = rootRef.current;
    if (!root || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(([entry]) =>
      setOffscreen(!entry.isIntersecting),
    );
    observer.observe(root);
    return () => observer.disconnect();
  }, []);

  const safeSize = size > 0 ? size : DEFAULT_SIZE;
  const centerRatio = ratio(centerSize / safeSize);
  const chipRatio = ratio(chipSize / safeSize);

  const vars: CSSVars = {};
  if (safeSize !== DEFAULT_SIZE) vars["--oi-size"] = `${safeSize}px`;
  if (centerRatio !== ratio(DEFAULT_CENTER_RATIO)) vars["--oi-center"] = centerRatio;
  if (chipRatio !== ratio(DEFAULT_CHIP_RATIO)) vars["--oi-chip"] = chipRatio;
  if (glowColor.toLowerCase() !== DEFAULT_GLOW) vars["--oi-glow"] = glowColor;

  // Read once, in ring order; duplicates (the same service on two rings)
  // are announced once.
  const labels = [
    ...new Set(rings.flatMap((ring) => ring.items.map((item) => item.label))),
  ];

  return (
    <div
      ref={rootRef}
      className={className ? `${styles.root} ${className}` : styles.root}
      role={ariaLabel ? "group" : undefined}
      aria-label={ariaLabel}
      data-ring-style={ringStyle}
      data-pause-on-hover={pauseOnHover || undefined}
      data-offscreen={offscreen || undefined}
      style={vars}
    >
      {/* The orbit layer carries no information beyond the labels listed
          below, so assistive tech skips it entirely. */}
      <div className={styles.orbits} aria-hidden>
        <div className={styles.ambient} />
        <div className={styles.glow} />
        {rings.map((ring, ringIndex) => {
          const radius = ring.radius > 1 ? ring.radius / safeSize : ring.radius;
          const count = ring.items.length;
          // Stagger the rings' starting angles so chips never line up radially.
          const offset = count ? (360 / count) * (ringIndex * 0.5) + ringIndex * 17 : 0;
          const ringVars: CSSVars = {
            "--oi-r": ratio(Math.max(0, radius)),
            "--oi-duration": `${Math.max(MIN_SPEED, ring.speed)}s`,
          };
          return (
            <div
              key={ringIndex}
              className={styles.ring}
              data-reverse={ring.reverse || undefined}
              style={ringVars}
            >
              {showRings && <div className={styles.line} />}
              {ring.items.map((item, i) => {
                const angle = offset + (360 / count) * i;
                // Golden-ratio spread gives each beam a pseudo-random slot
                // in the cycle, so pulses feel occasional rather than marching.
                const phase = (ringIndex * 0.31 + i * 0.618) % 1;
                const spokeVars: CSSVars = {
                  "--oi-angle": `${angle.toFixed(2)}deg`,
                  "--oi-beam-delay": `${(-phase * BEAM_CYCLE).toFixed(2)}s`,
                };
                const Icon = item.icon;
                return (
                  <div key={`${item.label}-${i}`} className={styles.spoke} style={spokeVars}>
                    {showBeams && <span className={styles.beam} />}
                    <span className={styles.chipAnchor}>
                      <span className={styles.chip} title={item.label}>
                        {item.node ?? (Icon ? <Icon aria-hidden strokeWidth={1.75} /> : null)}
                      </span>
                    </span>
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>

      {/* Outside the hidden layer: a logo's alt text / name stays reachable. */}
      <div className={styles.center}>{children}</div>

      {labels.length > 0 && (
        <ul className={styles.srOnly}>
          {labels.map((label) => (
            <li key={label}>{label}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default OrbitingIcons;
```

#### `components/orbiting-icons/OrbitingIcons.module.css`
```css
/* ==========================================================================
   Orbiting icons

   root (square, container for cqi) → orbits (aria-hidden) → ring (rotates)
   → spoke (static angle) → chipAnchor (static counter-angle) → chip (counter-
   rotates in lockstep with its ring). Ring and chip share one duration and
   direction, so their rotations cancel and icons stay upright. Every length
   is a fraction of the root width in cqi: the visual scales like an image.
   ========================================================================== */

.root {
  /* Defaults. The component only sets these inline for non-default
     values, so the default render is exactly this stylesheet. The two
     ratios are 104 / 420 and 44 / 420 (center / chip over size). */
  --oi-size: 420px;
  --oi-center: 0.24762;
  --oi-chip: 0.10476;
  --oi-glow: #8b7bff;

  position: relative;
  width: min(100%, var(--oi-size));
  aspect-ratio: 1;
  /* Makes cqi below resolve against this box — not the viewport — so the
     layout fits any container, including a scaled preview. */
  container-type: inline-size;
  isolation: isolate;
}

.orbits {
  position: absolute;
  inset: 0;
}

/* Wide, faint light pool behind the rings: gives the dark stage depth. */
.ambient {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(
    circle at center,
    color-mix(in srgb, var(--oi-glow) 16%, transparent) 0%,
    transparent 64%
  );
  pointer-events: none;
}

/* Soft halo hugging the center disc; breathes slowly. */
.glow {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--oi-center) * 270cqi);
  aspect-ratio: 1;
  translate: -50% -50%;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    color-mix(in srgb, var(--oi-glow) 62%, transparent) 0%,
    color-mix(in srgb, var(--oi-glow) 22%, transparent) 34%,
    transparent 68%
  );
  pointer-events: none;
  animation: oi-breathe 6s ease-in-out infinite alternate;
}

/* --- Rings. Centered with `translate` and spun with `transform`: the
   individual property composes before transform, so the two never fight. */
.ring {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--oi-r) * 200cqi);
  aspect-ratio: 1;
  translate: -50% -50%;
  animation: oi-spin var(--oi-duration) linear infinite;
}

.ring[data-reverse] {
  animation-direction: reverse;
}

.line {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  pointer-events: none;
}

/* 1.5px rather than a hairline: the gallery card scales the stage to ~0.4. */
.root[data-ring-style="dashed"] .line {
  border: 1.5px dashed rgba(255, 255, 255, 0.17);
}

.root[data-ring-style="solid"] .line {
  border: 1.5px solid rgba(255, 255, 255, 0.1);
}

/* A faint full circle plus a lit comet arc whose head leads the motion.
   The ring itself rotates, so the arc travels with no extra animation.
   The mask trims the fill to a thin band; unprefixed only — the build adds
   -webkit-, and writing both lets Lightning CSS drop the unprefixed one. */
.root[data-ring-style="gradient"] .line {
  background:
    conic-gradient(
      from 0deg,
      transparent 0deg,
      transparent 140deg,
      color-mix(in srgb, var(--oi-glow) 70%, transparent) 300deg,
      color-mix(in srgb, var(--oi-glow) 55%, white) 352deg,
      transparent 360deg
    ),
    rgba(255, 255, 255, 0.07);
  mask-image: radial-gradient(
    farthest-side,
    transparent calc(100% - 2px),
    #000 calc(100% - 1.5px)
  );
}

/* Mirror the arc on counter-clockwise rings so the bright head still
   leads instead of trailing. */
.root[data-ring-style="gradient"] .ring[data-reverse] .line {
  transform: scaleX(-1);
}

/* --- Spokes: a zero-height bar from the ring center to the orbit, turned
   to the item's angle. Static, so the spread survives reduced motion. */
.spoke {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 50%;
  height: 0;
  transform-origin: 0 50%;
  rotate: var(--oi-angle);
}

/* Beam track: from the center disc's rim to the chip's edge. Clips the
   pulse so it appears out of the chip and vanishes into the center. */
.beam {
  position: absolute;
  top: -3px;
  left: calc(var(--oi-center) * 50cqi);
  right: calc(var(--oi-chip) * 50cqi);
  height: 6px;
  overflow: hidden;
  pointer-events: none;
}

/* The pulse: a short streak with its bright head toward the center.
   35% of the track wide, so 286% starts it just past the outer end. */
.beam::before {
  content: "";
  position: absolute;
  top: 2px;
  left: 0;
  width: 35%;
  height: 2px;
  border-radius: 2px;
  background: linear-gradient(
    90deg,
    #fff,
    var(--oi-glow) 30%,
    transparent
  );
  box-shadow: 0 0 6px var(--oi-glow);
  opacity: 0;
  /* 7s matches BEAM_CYCLE in the component, which spreads the delays. */
  animation: oi-beam 7s linear infinite;
  animation-delay: var(--oi-beam-delay);
}

/* --- Chips. The anchor undoes the spoke angle (static); the chip undoes
   the ring rotation (animated, same duration and direction as its ring). */
.chipAnchor {
  position: absolute;
  left: 100%;
  top: 0;
  width: calc(var(--oi-chip) * 100cqi);
  aspect-ratio: 1;
  translate: -50% -50%;
  rotate: calc(var(--oi-angle) * -1);
}

.chip {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  overflow: hidden;
  /* Opaque base so the orbit line doesn't show through the glass. */
  background:
    radial-gradient(
      120% 120% at 30% 15%,
      rgba(255, 255, 255, 0.15),
      rgba(255, 255, 255, 0.02) 62%
    ),
    #111115;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.2),
    inset 0 0 0 1px rgba(255, 255, 255, 0.09),
    0 8px 22px rgba(0, 0, 0, 0.55);
  color: rgba(255, 255, 255, 0.86);
  animation: oi-counter var(--oi-duration) linear infinite;
  transition:
    color 200ms ease,
    box-shadow 200ms ease;
}

.ring[data-reverse] .chip {
  animation-direction: reverse;
}

.chip:hover {
  color: #fff;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.28),
    inset 0 0 0 1px color-mix(in srgb, var(--oi-glow) 60%, transparent),
    0 0 18px color-mix(in srgb, var(--oi-glow) 45%, transparent),
    0 8px 22px rgba(0, 0, 0, 0.55);
}

.chip > svg {
  width: 46%;
  height: 46%;
}

.chip > img {
  width: 58%;
  height: 58%;
  object-fit: contain;
}

/* --- Center disc: dark glass with a lit top edge, a thin outer rim and a
   glow in the accent color. The slot accepts any logo. */
.center {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 1;
  display: grid;
  place-items: center;
  width: calc(var(--oi-center) * 100cqi);
  aspect-ratio: 1;
  translate: -50% -50%;
  border-radius: 50%;
  background:
    radial-gradient(
      100% 100% at 50% 0%,
      rgba(255, 255, 255, 0.16),
      rgba(255, 255, 255, 0.03) 62%
    ),
    #0f0f13;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.24),
    inset 0 0 0 1px rgba(255, 255, 255, 0.1),
    0 0 44px color-mix(in srgb, var(--oi-glow) 42%, transparent),
    0 20px 50px rgba(0, 0, 0, 0.5);
  color: #fff;
}

.center::before {
  content: "";
  position: absolute;
  inset: -9%;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.08);
  pointer-events: none;
}

.center > svg {
  width: 42%;
  height: 42%;
}

/* --- Pausing. Ring, chip and beam must stop together: a ring paused
   without its chips would tip the icons over. play-state freezes in place,
   so resuming has no jump. */
.root[data-pause-on-hover]:hover .ring,
.root[data-pause-on-hover]:hover .chip,
.root[data-pause-on-hover]:hover .beam::before,
.root[data-offscreen] .ring,
.root[data-offscreen] .chip,
.root[data-offscreen] .beam::before,
.root[data-offscreen] .glow {
  animation-play-state: paused;
}

@keyframes oi-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

@keyframes oi-counter {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(-360deg);
  }
}

@keyframes oi-breathe {
  from {
    transform: scale(0.92);
    opacity: 0.75;
  }
  to {
    transform: scale(1.06);
    opacity: 1;
  }
}

/* Travel during the first ~22% of the cycle, then rest — "occasionally". */
@keyframes oi-beam {
  0% {
    transform: translateX(286%);
    opacity: 0;
  }
  3% {
    opacity: 1;
  }
  18% {
    opacity: 1;
  }
  22% {
    transform: translateX(-100%);
    opacity: 0;
  }
  100% {
    transform: translateX(-100%);
    opacity: 0;
  }
}

/* Reduced motion: the spokes already spread the chips upright, so turning
   the animations off leaves a composed, static constellation. */
@media (prefers-reduced-motion: reduce) {
  .ring,
  .chip,
  .glow {
    animation: none;
  }

  .beam {
    display: none;
  }
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/orbiting-icons.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { OrbitingIcons } from "@/components/orbiting-icons";
import { PenTool, Palette, Code2, Terminal, Database, Cloud, Zap, Globe, Lock, BarChart3, Sparkles } from "lucide-react";

// TODO: children を自分のロゴ(<img alt="…"> や SVG)に差し替えてください。中央の円の中に配置されます。<OrbitingIcons …>…</OrbitingIcons> の形で書いても同じです
// TODO: rings の icon / label を実際の連携先に差し替えてください。radius は 1 以下ならサイズに対する割合、1 より大きければ px、speed は 1 周の秒数です
<OrbitingIcons
  size={420}
  centerSize={104}
  chipSize={44}
  showRings={true}
  ringStyle="gradient"
  glowColor="#8b7bff"
  pauseOnHover={true}
  showBeams={true}
  aria-label="連携サービス"
  rings={[
    {
      radius: 0.25,
      speed: 28,
      items: [
        { icon: PenTool, label: "デザイン" },
        { icon: Palette, label: "カラー" },
        { icon: Code2, label: "コード" },
        { icon: Terminal, label: "ターミナル" },
      ],
    },
    {
      radius: 0.42,
      speed: 44.8,
      reverse: true,
      items: [
        { icon: Database, label: "データベース" },
        { icon: Cloud, label: "クラウド" },
        { icon: Zap, label: "自動化" },
        { icon: Globe, label: "Web" },
        { icon: Lock, label: "認証" },
        { icon: BarChart3, label: "分析" },
      ],
    },
  ]}
  children={<Sparkles aria-hidden="true" />}
/>
```

### 4. 組み込み手順
1. 連携先を `rings` に内側から順に並べる: `<OrbitingIcons rings={[{ radius: 0.25, speed: 28, items: [{ icon: PenTool, label: "デザイン" }] }]} aria-label="連携サービス">{logo}</OrbitingIcons>`。リングは 1〜3 本が想定。
2. `radius` は **1 以下ならサイズに対する割合**(0.5 で外枠に接する)、1 より大きければ `size` 基準の px。`speed` は 1 周の秒数、`reverse: true` で逆回転。
3. 中央のロゴは `children` に渡す(`<img alt="…">` や SVG)。アイコン(svg)を直接渡すと円の 42% の大きさになる。
4. アイコンは `lucide-react` のコンポーネントをそのまま `icon` に渡せる(`className` を受け取るアイコンなら他のライブラリでも可)。ロゴ画像などは `node` に渡すと `icon` より優先される。
5. 配置するだけでよい。幅は `min(100%, size)`・正方形で、狭いコンテナでは比率を保って縮む。中央寄せは親側で行う(例: 親に `display: grid; place-items: center`)。
6. `npm run build` が通ることを確認し、「アイコンが傾かず正立したまま回る」「ホバーで全体が止まる」「OS の視差効果を減らす設定で静止し、アイコンが均等に並ぶ」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 回っているうちにアイコンが傾く | リングとチップの `animation-duration` / `animation-direction` / `animation-play-state` がずれている | チップはリングと同じ `--oi-duration`・同じ向きで逆回転させている。一時停止のセレクタもリング・チップ・ビームを必ずまとめて止める |
| 視差効果オフで配置が崩れる / アイコンが重なる | 角度をアニメーションの遅延(負の `animation-delay`)で散らしている | 角度は静的な「スポーク」の `rotate` に持たせ、チップ側で `rotate: -角度` を打ち消す構造を崩さない |
| サイズがウィンドウ幅で変わる / プレビューで巨大になる | 長さを `vw` / `vh` で指定した | すべてルート幅基準の `cqi`。ルートの `container-type: inline-size` を外さない |
| 中心がずれて回る | `transform` で中央寄せと回転を兼ねている | 中央寄せは `translate` プロパティ、回転は `transform`(アニメーション)と分けている。まとめない |
| Chrome でグラデーションの軌道線が塗りつぶしの円になる | `mask-image` と `-webkit-mask-image` を併記すると Next.js(Lightning CSS)が unprefixed を削ることがある | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| スクリーンリーダーが何も読まない / 同じ名前を何度も読む | 軌道レイヤーの `aria-hidden` を外した、またはラベル一覧を消した | 動く層は `aria-hidden`、連携先の名前は視覚的に隠した `<ul>` で 1 回だけ読ませる構造を保つ |

### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。ランタイム依存なし(アイコンは利用側が `lucide-react` などから渡す)
- ファイル: `components/orbiting-icons/OrbitingIcons.tsx`(`"use client"`、IntersectionObserver のため)+ `OrbitingIcons.module.css`
- Props: `rings: { radius, speed, reverse?, items: { label, icon?, node? }[] }[]`、`size`(既定 420px)、`centerSize`(104)、`chipSize`(44)、`showRings`(true)、`ringStyle`(`"gradient" | "dashed" | "solid"`、既定 gradient)、`showBeams`(true)、`pauseOnHover`(true)、`glowColor`(`#8b7bff`)、`children`(中央)、`className`、`aria-label`
- CSS 変数(接頭辞 `--oi-`)の既定値はルートのクラスに置き、既定と異なる値だけインラインで上書きする

### 見た目
- **ルート**: `width: min(100%, var(--oi-size)); aspect-ratio: 1; container-type: inline-size`。長さはすべて「サイズに対する割合 × 100cqi」で表すので、画像のように縮む
- **層構成**(奥から): 環境光 → 中央の光 → リング(軌道線 + スポーク + チップ)→ 中央の円
- **環境光**: ルート全面に `radial-gradient(発光色 16%, transparent 64%)`
- **中央の光**: 中央の円の 2.7 倍、`発光色 62% → 22%(34%)→ transparent(68%)`、6 秒で `scale(.92) → 1.06` を往復
- **軌道線**(リング要素の中、リングと一緒に回る)
  - gradient: 白 .07 の円 + `conic-gradient`(140° まで透明 → 300° で発光色 70% → 352° で発光色と白の混色 → 360° で透明)の光の弧。`mask-image: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px))` で細い帯にする。逆回転のリングは `scaleX(-1)` で弧の頭を進行方向に向ける
  - dashed: `1.5px dashed` 白 .17 / solid: `1.5px solid` 白 .10(ギャラリーで 0.4 倍に縮むので細線にしない)
- **チップ**: 円形、`radial-gradient(120% 120% at 30% 15%, 白 .15, 白 .02 62%)` + 不透明の `#111115`(軌道線が透けない)、`inset 0 1px 0 白 .2` / `inset 0 0 0 1px 白 .09` / `0 8px 22px rgba(0,0,0,.55)`。アイコンは 46%、線幅 1.75、白 .86。ホバーで白・発光色の縁と外光
- **中央の円**: ダークガラス(`radial-gradient(100% 100% at 50% 0%, 白 .16, 白 .03 62%)` + `#0f0f13`)、上辺のハイライト、`0 0 44px 発光色 42%` の外光、外側 9% に白 .08 の細いリム。`children` を中央に配置
- **ビーム**: 中央の円の縁からチップの縁までのトラック(高さ 6px、`overflow: hidden`)を、幅 35%・高さ 2px の光の筋(頭が中央側で白 → 発光色 → 透明、`0 0 6px` の発光)が走る

### モーション
- 構造: リング(`transform: rotate(0 → 360deg)`、`linear`、`speed` 秒)→ スポーク(静的に `rotate: 角度`、長さ = 半径)→ チップのアンカー(静的に `rotate: -角度`)→ チップ(`rotate(0 → -360deg)`、リングと同じ周期・同じ向き)。回転が打ち消し合い、アイコンは常に正立
- 各リングのアイテムは `360 / 個数` 間隔。リングごとに開始角をずらし、放射状に並ばないようにする
- ビーム: 7 秒周期の最初の約 22% で外→中央へ移動し、残りは非表示。各スポークの遅延は黄金比で散らした負の `animation-delay` なので「ときどき」走って見える
- `pauseOnHover`: ルートのホバーでリング・チップ・ビームの `animation-play-state` を**まとめて** paused(片方だけ止めるとアイコンが傾く)。画面外では IntersectionObserver で同様に停止
- `prefers-reduced-motion: reduce`: リング・チップ・中央の光のアニメーションを無効、ビームを非表示。角度はスポークにあるので、均等に並んだ静止状態になる

### アクセシビリティ
- 動く軌道レイヤーは丸ごと `aria-hidden`(動きに情報はない)。連携先の名前は視覚的に隠した `<ul>` に重複を除いて 1 回ずつ出す — `role="img"` だと名前が 1 つの文字列に潰れ、中央のロゴの代替テキストも読まれなくなるため、数えて移動できるリストを選んだ
- 中央の `children` は隠しレイヤーの外に置き、ロゴの `alt` や名前が読めるようにする
- `aria-label` を渡すとルートが `role="group"` になり、全体に名前が付く
- 各チップに `title` を付け、マウス利用者にはホバー(停止中)でラベルを見せる。チップはフォーカス不可(操作要素ではない)

### 受け入れ条件
- 既定で 2 本のリングが逆向きに回り、アイコンが傾かない
- コンテナを 240px まで狭めても比率を保ったまま縮み、はみ出さない(`vw` / `vh` を使っていない)
- ホバーで全体が止まり、再開時に飛ばない
- 視差効果を減らす設定で静止し、アイコンが各軌道上に均等に並ぶ
- スクリーンリーダーで連携先の名前の一覧と中央のロゴ名が読める
- ランタイムの依存パッケージを追加しない
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/orbiting-icons.json
生成コード
import { OrbitingIcons } from "@/components/orbiting-icons";
import { PenTool, Palette, Code2, Terminal, Database, Cloud, Zap, Globe, Lock, BarChart3, Sparkles } from "lucide-react";

// TODO: children を自分のロゴ(<img alt="…"> や SVG)に差し替えてください。中央の円の中に配置されます。<OrbitingIcons …>…</OrbitingIcons> の形で書いても同じです
// TODO: rings の icon / label を実際の連携先に差し替えてください。radius は 1 以下ならサイズに対する割合、1 より大きければ px、speed は 1 周の秒数です
<OrbitingIcons
  size={420}
  centerSize={104}
  chipSize={44}
  showRings={true}
  ringStyle="gradient"
  glowColor="#8b7bff"
  pauseOnHover={true}
  showBeams={true}
  aria-label="連携サービス"
  rings={[
    {
      radius: 0.25,
      speed: 28,
      items: [
        { icon: PenTool, label: "デザイン" },
        { icon: Palette, label: "カラー" },
        { icon: Code2, label: "コード" },
        { icon: Terminal, label: "ターミナル" },
      ],
    },
    {
      radius: 0.42,
      speed: 44.8,
      reverse: true,
      items: [
        { icon: Database, label: "データベース" },
        { icon: Cloud, label: "クラウド" },
        { icon: Zap, label: "自動化" },
        { icon: Globe, label: "Web" },
        { icon: Lock, label: "認証" },
        { icon: BarChart3, label: "分析" },
      ],
    },
  ]}
  children={<Sparkles aria-hidden="true" />}
/>