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
プレビュー
"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