First CH Designs
BLK-63Blocks

回転ダイヤル型サイトインデックス

サイト全体の行き先を円周に並べ、環ごと回して頭に来たものを選ぶナビゲーション。水平リストやタブと違い、選択面そのものが回る。円周配置はCSSの三角関数だけで書き(--a から cos/sin で translate)、環の回転角は --rot という変数1つに集約してあるので、ラベルの正立は calc(var(--rot) * -1) の逆回転がCSS側で従属する(項目ごとに角度をJSで書き戻さない)。角度は0〜360に丸めず連続値で貯め、差分を±180°へ折り返してから足すので8番目→1番目でも大回りしない。掴んで回すドラッグは中心からのatan2の差分で、8px動くまでsetPointerCaptureしない(先に捕まえるとclickの発火先を奪って項目が押せなくなる)。キーボードは矢印/Home/Endで送れ、:focus-visible で来たフォーカスだけを選択に繋ぐため「到達=選択」になり、Enterは常に遷移になる。中央のハブと右の案内窓は回らない側に置き、選択中の行き先の下位ページまで出す。prefers-reduced-motionでは環を止め、代わりに指標のティックを選択中の角度へ置き直す降格を用意。ライブラリ依存ゼロ・画像素材ゼロ。

追加日:
2026-09-16
依存:
なし
tags
#navigation #index #radial #dial #css-trig #drag #keyboard #a11y #reduced-motion #no-dependency #no-image

プレビュー

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

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

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>
  );
}

shadcn CLI でプロジェクトに追加

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