First CH Designs
BLK-63Blocks

Radial Index Dial Navigation

A navigation that lays every destination on a circle and turns the whole ring to bring the chosen one to the top — unlike a horizontal list or tabs, the selection surface itself rotates. Items are placed with CSS trigonometry alone (translate driven by cos/sin of a per-item --a), and the ring's angle lives in a single --rot custom property, so keeping labels upright is just calc(var(--rot) * -1) inherited in CSS rather than an angle written back per item from JS. Angles accumulate as continuous values and each delta is folded into ±180°, so going from the eighth stop to the first never takes the long way round. Drag-to-spin uses the difference of atan2 from the ring centre and only calls setPointerCapture after 8px of movement, because capturing earlier steals the click target from the stops. Arrow/Home/End move the dial, and only focus that arrives via :focus-visible selects, which makes reaching an item the same as choosing it for keyboard users while Enter always navigates. The hub and the side panel stay still and carry the selected destination plus its child pages. Under prefers-reduced-motion the ring stops and the amber tick moves to the selected angle instead. No dependencies, no image assets.

Added:
2026-09-16
Dependencies:
None
tags
#navigation #index #radial #dial #css-trig #drag #keyboard #a11y #reduced-motion #no-dependency #no-image

Preview

行き先を円周に載せた、回して選ぶ目次

サイト全体の八つの行き先を環に並べています。掴んで回すか、矢印キーで送ると、頭に来た行き先の中身が案内窓に出ます。

01

サービス

設計・制作・運用・集客までを担当を切らずに受け持ちます。必要なところだけの部分依頼もできます。

この行き先の下に12ページあります

"use client";

import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["600", "700"],
  subsets: ["latin"],
  display: "swap",
  preload: false,
});

/**
 * 回転式サイトインデックス(BLK-63)
 *
 * 差別化メモ: 既存の切り替え表現(six-faces-cube / text-swap-tabs)は「選んだ中身が回転面に載って
 * 正面に来る」コンテンツ切替で、回るのは内容そのものだった。本ブロックで回るのは行き先ラベルの環だけで、
 * 中央と右は固定の案内窓として選択中の行き先+その下位ページを出す=回る"目次"(ナビ)であり、
 * 既存ナビ(site-header / footer-directory)が水平・直線の一覧なのに対し選択面そのものが回る。
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点)
 *
 * 1. 円周配置は CSS の三角関数だけで書ける(JS で座標を計算しない)
 *      li { --a: calc(360deg / var(--n) * var(--i) - 90deg);
 *           translate: calc(cos(var(--a)) * var(--r)) calc(sin(var(--a)) * var(--r)); }
 *    項目は全て `absolute inset-0` の同じ箱+`place-items-center` にしておき、箱ごと translate で
 *    円周上へ運ぶ。半径 --r と件数 --n が変数なので、ブレークポイントで半径を変えても、項目を
 *    増やしても、JS 側は一行も変わらない(要素の実寸を測る処理が一切要らない)。
 *    -90deg は「0番目を真上に置く」ためのオフセット。
 *
 * 2. ラベルを正立させる回転は「同じ変数に -1 を掛ける」だけ
 *      ul   { rotate: var(--rot); }
 *      chip { rotate: calc(var(--rot) * -1); }
 *    環の回転角を CSS 変数として1か所(nav)に置くと、逆回転は CSS の計算で従属する。項目ごとに
 *    JS で角度を書き戻す必要がなく、件数が増えても更新は変数1つ。両者の transition を同じ
 *    duration / easing に揃えるのが条件(ズレると回転中だけ文字が傾いて見える)。
 *
 * 3. 角度は 0〜360 に丸めず連続値で貯める(最短回転)
 *      delta = ((target - current) % 360 + 540) % 360 - 180   // 常に ±180° 以内
 *      rot += delta
 *    mod 360 で持つと 8番目→1番目で毎回逆回りに大回りする。連続値なら何周でも同じ式で足せる。
 *
 * 4. 掴んで回す(円のドラッグ)は中心からの atan2 の差分で足す
 *    pointerdown では capture せず、移動が 8px を超えた時点で初めて setPointerCapture する。
 *    先に capture すると、その後の click の発火先が奪われて項目が押せなくなる。指を離したら
 *    step の倍数へ丸めて吸着し、丸めた角度から選択中の index を逆算する(状態は角度が正、
 *    index は従属)。縦スクロールを殺さないため touch-action は pan-y に留め、pointercancel も拾う。
 *
 * 5. focus で選択するかどうかを `:focus-visible` で分岐する
 *    onFocus 内で `event.target.matches(":focus-visible")` を見ると、キーボードで来たフォーカスと
 *    クリックに伴うフォーカスを JS 側で区別できる。キーボードでは Tab / 矢印でフォーカスした項目が
 *    そのまま環の頭に上がってくる(=到達=選択なので Enter は常に遷移)、マウスでは
 *    「1回目のクリックで環を回し、選択済みをもう一度押すと遷移する」ダイヤル操作に分かれる。
 *    どちらも focus-visible のリングは CSS(focus-visible:outline-*)だけで成立する。
 *
 * 6. prefers-reduced-motion では「回すのをやめ、指し示すのはやめない」
 *    環の回転そのものが酔いの原因なので、--rot を 0 に固定して環を止め、代わりに上部に固定して
 *    いた指標(アンバーのティック)を選択中の項目の角度へ置き直す。マークアップも操作も同じまま、
 *    動くものが「面」から「印」へ入れ替わる。指標の位置決めには項目と同じ cos/sin の式を使う。
 *    ドラッグは回転が見えない状態では意味を失うので、この時だけ無効化する。
 *
 * 着想は「項目を円周に並べ、環を回して選ぶ」という構造と、ラベルを逆回転で正立させる仕組みのみ。
 * 文言・配色・レイアウト・コードはすべて First CH のオリジナルとして書き起こしている
 * (外部コード・素材の取り込みは無し)。依存ゼロ・画像素材ゼロ。
 */

type Destination = {
  /** 環の上に置く短いラベル。読み上げ名(label)に必ず含まれる文字列にする(Label in Name) */
  short: string;
  label: string;
  lead: string;
  children: string[];
  pages: number;
};

const DESTINATIONS: Destination[] = [
  {
    short: "サービス",
    label: "サービス",
    lead: "設計・制作・運用・集客までを担当を切らずに受け持ちます。必要なところだけの部分依頼もできます。",
    children: ["制作メニュー", "運用・保守", "広告と集客"],
    pages: 12,
  },
  {
    short: "実績",
    label: "制作実績",
    lead: "業種・目的・規模で絞り込める事例集です。公開後にどう数字が動いたかまで載せています。",
    children: ["業種から探す", "目的から探す", "最近公開したもの"],
    pages: 24,
  },
  {
    short: "強み",
    label: "強み・選ばれる理由",
    lead: "内製できる範囲と、返事の速さと、公開後に残るものの三つで比べてもらうためのページです。",
    children: ["体制と進め方", "内製の範囲", "他の選択肢との比較"],
    pages: 6,
  },
  {
    short: "料金",
    label: "料金と見積り",
    lead: "何にいくらかかるのかを先に開示します。追加費用が出る条件も同じページに書いています。",
    children: ["料金の考え方", "プラン比較", "概算を出す"],
    pages: 5,
  },
  {
    short: "流れ",
    label: "制作の流れ",
    lead: "初回の相談から公開、その後の運用までを八つの工程に分けて、所要と担当を示しています。",
    children: ["相談から公開まで", "公開後の運用", "納期の目安"],
    pages: 4,
  },
  {
    short: "お知らせ",
    label: "お知らせ",
    lead: "更新情報と掲載実績、休業のご案内をまとめています。重要なものは先頭に固定しています。",
    children: ["最新の更新", "メディア掲載", "休業のご案内"],
    pages: 18,
  },
  {
    short: "会社案内",
    label: "会社案内",
    lead: "会社の基本情報と、どこで誰が作っているかを開示しています。地図と交通手段も同じページです。",
    children: ["会社概要", "拠点とアクセス", "沿革"],
    pages: 7,
  },
  {
    short: "採用",
    label: "採用情報",
    lead: "募集中の職種と、働き方と、選考で見ているところを書いています。見学だけの応募も受け付けます。",
    children: ["募集職種", "働き方と制度", "エントリー"],
    pages: 9,
  },
];

const COUNT = DESTINATIONS.length;
const STEP = 360 / COUNT;

/** 差分を ±180° の範囲へ折り返す(最短回転。連続値の rot に足して使う) */
const shortestDelta = (from: number, to: number) => (((to - from) % 360) + 540) % 360 - 180;

/** 0番目を真上に置いたときの、index 番目の角度(CSS 側の --a と同じ式) */
const angleOf = (index: number) => index * STEP - 90;

const pad = (n: number) => String(n).padStart(2, "0");

/** 環と正立ラベルは同じ transition を共有する(ズレると回転中だけ文字が傾く) */
const SPIN =
  "transition-[rotate] duration-[820ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";

export default function RadialIndexDial() {
  const [active, setActive] = useState(0);
  const [rot, setRot] = useState(0);
  const [dragging, setDragging] = useState(false);
  const [flat, setFlat] = useState(false);
  const [swapping, setSwapping] = useState(false);

  const ringRef = useRef<HTMLDivElement | null>(null);
  const stopsRef = useRef<(HTMLAnchorElement | null)[]>([]);
  const flatRef = useRef(false);
  const activeRef = useRef(0);
  const firstRun = useRef(true);
  /** 角度の正本は ref 側(ドラッグ中は1フレームに何度も読み書きするため) */
  const rotRef = useRef(0);
  const suppressClick = useRef(false);
  /** ドラッグ中の作業領域(再描画を伴わない値はすべて ref に置く) */
  const drag = useRef({ id: -1, down: false, moved: false, last: 0, x: 0, y: 0 });

  // reduced-motion 判定はマウント後に反映する(初期描画は SSR と同じなのでハイドレーション差分は出ない)
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const apply = () => {
      flatRef.current = mq.matches;
      setFlat(mq.matches);
    };
    apply();
    mq.addEventListener("change", apply);
    return () => mq.removeEventListener("change", apply);
  }, []);

  // 右の案内窓は、環が回り始めるのと同じタイミングで軽く差し替える
  useEffect(() => {
    if (firstRun.current) {
      firstRun.current = false;
      return;
    }
    setSwapping(true);
    const id = requestAnimationFrame(() => setSwapping(false));
    return () => cancelAnimationFrame(id);
  }, [active]);

  const spinTo = (deg: number) => {
    rotRef.current = deg;
    setRot(deg);
  };

  const goTo = (index: number) => {
    const next = ((index % COUNT) + COUNT) % COUNT;
    activeRef.current = next;
    setActive(next);
    spinTo(rotRef.current + shortestDelta(rotRef.current, -next * STEP));
  };

  /** 環の中心から見たポインタの角度(deg) */
  const angleAt = (clientX: number, clientY: number) => {
    const box = ringRef.current?.getBoundingClientRect();
    if (!box) return 0;
    const dx = clientX - (box.left + box.width / 2);
    const dy = clientY - (box.top + box.height / 2);
    return (Math.atan2(dy, dx) * 180) / Math.PI;
  };

  const onPointerDown = (e: React.PointerEvent) => {
    if (flatRef.current || e.button !== 0) return;
    suppressClick.current = false;
    drag.current = {
      id: e.pointerId,
      down: true,
      moved: false,
      last: angleAt(e.clientX, e.clientY),
      x: e.clientX,
      y: e.clientY,
    };
  };

  const onPointerMove = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d.down || e.pointerId !== d.id) return;
    if (!d.moved) {
      // 8px 動くまでは capture しない。先に capture すると click の発火先を奪って項目が押せなくなる
      if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 8) return;
      d.moved = true;
      e.currentTarget.setPointerCapture(e.pointerId);
      setDragging(true);
    }
    const now = angleAt(e.clientX, e.clientY);
    const delta = shortestDelta(d.last, now);
    d.last = now;
    spinTo(rotRef.current + delta);
  };

  const endDrag = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d.down || e.pointerId !== d.id) return;
    d.down = false;
    if (!d.moved) return;
    d.moved = false;
    suppressClick.current = true;
    setDragging(false);
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
    // 角度が正・index は従属: 丸めた角度から選択中を逆算する
    const snapped = Math.round(rotRef.current / STEP) * STEP;
    const index = ((Math.round(-snapped / STEP) % COUNT) + COUNT) % COUNT;
    activeRef.current = index;
    setActive(index);
    spinTo(snapped);
  };

  /** キーボードで来たフォーカスだけ「到達=選択」にする(クリックに伴うフォーカスは無視) */
  const onStopFocus = (index: number) => (e: React.FocusEvent<HTMLAnchorElement>) => {
    if (index === activeRef.current) return;
    if (!e.target.matches(":focus-visible")) return;
    goTo(index);
  };

  const onStopClick = (index: number) => (e: React.MouseEvent) => {
    if (suppressClick.current) {
      // 回し終わりの指離しをリンク遷移に取られない
      suppressClick.current = false;
      e.preventDefault();
      return;
    }
    if (index === activeRef.current) return; // 選択済みをもう一度=遷移(href をそのまま使う)
    e.preventDefault();
    goTo(index);
  };

  const onStopKeyDown = (e: React.KeyboardEvent) => {
    suppressClick.current = false;
    const last = COUNT - 1;
    let next: number | null = null;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = active === last ? 0 : active + 1;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = active === 0 ? last : active - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    if (next === null) return;
    e.preventDefault();
    goTo(next);
    stopsRef.current[next]?.focus();
  };

  const current = DESTINATIONS[active];

  return (
    <section className="@container w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
      <div className="px-6 pt-8 @2xl:px-10 @2xl:pt-10">
        <h2 className="text-[clamp(1.35rem,3.2vw,1.95rem)] font-bold tracking-tight text-stone-900">
          行き先を円周に載せた、回して選ぶ目次
        </h2>
        <p className="mt-3 max-w-md text-sm leading-[1.9] text-stone-600">
          サイト全体の八つの行き先を環に並べています。掴んで回すか、矢印キーで送ると、頭に来た行き先の中身が案内窓に出ます。
        </p>
      </div>

      <div className="mt-7 grid grid-cols-[minmax(0,1fr)] border-t border-gray-200 @3xl:grid-cols-[minmax(0,1fr)_296px]">
        {/* ダイヤル本体 */}
        <nav
          aria-label="サイトインデックス"
          className="relative px-5 py-9 @xl:py-11"
          style={{ "--n": String(COUNT), "--rot": `${flat ? 0 : rot}deg` } as React.CSSProperties}
        >
          <div
            ref={ringRef}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={endDrag}
            onPointerCancel={endDrag}
            className={`relative mx-auto h-[calc(var(--r)*2_+_64px)] w-[calc(var(--r)*2_+_64px)] [--r:79px] [touch-action:pan-y] @xs:[--r:104px] @2xl:[--r:142px] ${
              flat ? "" : "cursor-grab active:cursor-grabbing"
            }`}
          >
            {/* 目盛りの台紙(同心の hairline 2本) */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute top-1/2 left-1/2 h-[calc(var(--r)*2)] w-[calc(var(--r)*2)] -translate-x-1/2 -translate-y-1/2 rounded-full border border-stone-900/10"
            />
            <div
              aria-hidden="true"
              className="pointer-events-none absolute top-1/2 left-1/2 h-[calc(var(--r)*2_-_68px)] w-[calc(var(--r)*2_-_68px)] -translate-x-1/2 -translate-y-1/2 rounded-full border border-dashed border-stone-900/10"
            />

            {/* 指標: 通常は真上に固定(環が回る)/reduced-motion では選択中の角度へ置き直す(環は止まる) */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0 grid place-items-center [--rm:calc(var(--r)_+_20px)] [translate:calc(cos(var(--a))*var(--rm))_calc(sin(var(--a))*var(--rm))]"
              style={{ "--a": `${flat ? angleOf(active) : -90}deg` } as React.CSSProperties}
            >
              <span className="h-4 w-px bg-amber-600 [rotate:calc(var(--a)_+_90deg)]" />
            </div>

            {/* 環: 回るのはこの層だけ */}
            <ul
              className={`absolute inset-0 list-none [rotate:var(--rot)] ${dragging ? "transition-none" : SPIN}`}
            >
              {DESTINATIONS.map((item, i) => {
                const isActive = i === active;
                return (
                  <li
                    key={item.label}
                    className="pointer-events-none absolute inset-0 grid place-items-center [--a:calc(360deg/var(--n)*var(--i)_-_90deg)] [translate:calc(cos(var(--a))*var(--r))_calc(sin(var(--a))*var(--r))]"
                    style={{ "--i": String(i) } as React.CSSProperties}
                  >
                    <a
                      ref={(el) => {
                        stopsRef.current[i] = el;
                      }}
                      href="#"
                      // a の既定のドラッグ(リンクのD&D)が始まると pointercancel で環を掴めなくなる
                      draggable={false}
                      aria-label={item.label}
                      aria-current={isActive ? "true" : undefined}
                      onFocus={onStopFocus(i)}
                      onClick={onStopClick(i)}
                      onKeyDown={onStopKeyDown}
                      className={`pointer-events-auto border px-2.5 py-[7px] text-[10.5px] font-bold whitespace-nowrap transition-colors duration-200 select-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-amber-600 motion-reduce:transition-none @2xl:px-3.5 @2xl:py-2 @2xl:text-xs [rotate:calc(var(--rot)*-1)] ${
                        dragging ? "transition-none" : SPIN
                      } ${
                        isActive
                          ? "border-amber-600 bg-amber-600 text-white"
                          : "border-stone-900/15 bg-white text-stone-600 hover:border-amber-600 hover:text-amber-700"
                      }`}
                    >
                      {item.short}
                    </a>
                  </li>
                );
              })}
            </ul>

            {/* 中央のハブ(回らない・環の穴に収まる) */}
            <div className="pointer-events-none absolute inset-0 grid place-items-center">
              <div className="grid place-items-center gap-1.5 text-center">
                <span
                  className={`${montserrat.className} text-[26px] leading-none font-bold text-stone-900 tabular-nums @2xl:text-[34px]`}
                >
                  {pad(active + 1)}
                </span>
                <span className="h-px w-5 bg-amber-600" />
                <span className="text-[10.5px] font-bold text-stone-500">{current.short}</span>
              </div>
            </div>
          </div>

          {/* 送り(前後)と現在位置 */}
          <div className="mt-7 flex items-center justify-center gap-4">
            <button
              type="button"
              onClick={() => goTo(active - 1)}
              aria-label="前の行き先へ"
              className="grid h-9 w-9 place-items-center border border-stone-900/15 bg-white text-stone-500 transition-colors duration-200 hover:border-amber-600 hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
            >
              <span className="h-2 w-2 rotate-[-135deg] border-t border-r border-current" />
            </button>
            <p
              className={`${montserrat.className} text-[11px] font-semibold tracking-[0.2em] text-stone-400 tabular-nums`}
            >
              {pad(active + 1)} / {pad(COUNT)}
            </p>
            <button
              type="button"
              onClick={() => goTo(active + 1)}
              aria-label="次の行き先へ"
              className="grid h-9 w-9 place-items-center border border-stone-900/15 bg-white text-stone-500 transition-colors duration-200 hover:border-amber-600 hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
            >
              <span className="h-2 w-2 rotate-45 border-t border-r border-current" />
            </button>
          </div>

          <p aria-live="polite" className="sr-only">
            {current.label}を選択中
          </p>
        </nav>

        {/* 案内窓: 回らない側。選択中の行き先と、その下にあるページを出す */}
        <div className="border-t border-gray-200 px-6 py-8 @3xl:flex @3xl:flex-col @3xl:justify-center @3xl:border-t-0 @3xl:border-l @3xl:px-8 @3xl:py-10">
          <div
            className={`transition-[opacity,translate] duration-500 ease-out motion-reduce:transition-none ${
              swapping ? "translate-y-1 opacity-0" : "translate-y-0 opacity-100"
            }`}
          >
            <p
              className={`${montserrat.className} text-[11px] font-bold text-amber-600 tabular-nums`}
            >
              {pad(active + 1)}
            </p>
            <h3 className="mt-2 text-lg font-bold tracking-tight text-stone-900 @3xl:text-xl">
              {current.label}
            </h3>
            <p className="mt-3 text-[12.5px] leading-[1.9] text-stone-600">{current.lead}</p>

            <ul className="mt-6 border-t border-stone-900/10">
              {current.children.map((child) => (
                <li key={child}>
                  <a
                    href="#"
                    className="group flex items-center justify-between gap-3 border-b border-stone-900/10 py-3 text-[12.5px] font-bold text-stone-800 transition-colors duration-200 hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                  >
                    {child}
                    <span className="h-px w-4 origin-right scale-x-0 bg-amber-600 transition-transform duration-300 group-hover:origin-left group-hover:scale-x-100 group-focus-visible:origin-left group-focus-visible:scale-x-100 motion-reduce:transition-none" />
                  </a>
                </li>
              ))}
            </ul>

            <p className="mt-5 text-[11px] leading-[1.8] text-stone-400">
              この行き先の下に
              <span className={`${montserrat.className} mx-1 font-semibold tabular-nums`}>
                {current.pages}
              </span>
              ページあります
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/radial-index-dial.json