Six-Faced Cube Service Switcher
A service block that puts six offerings on the six faces of a single cube; picking one spins the cube along the shortest path to bring that face forward. Every face is the same square pushed out along its own normal with `<rotation> translateZ(calc(var(--cube)/2))`, and the edge length lives in one CSS custom property so the box never stops being a box across breakpoints. Angles are accumulated as continuous values rather than wrapped to 0–360 — each target is snapped to the equivalent angle nearest the current one — so going from the fifth face back to the first never takes the long way around. A second preserve-3d wrapper holds a fixed rotateX(-9deg) rotateY(-15deg) attitude so two adjacent faces stay visible and the shape reads as a solid, keeping "which face is front" and "how the box sits" as separate rotations. Shading is driven by whether a face is front-facing rather than baked per face, so the front is always the lit one. The interaction is a plain vertical tablist with roving tabindex and arrow/Home/End keys; non-front faces are inert. Under prefers-reduced-motion the 3D is dropped for a flat panel swap. No dependencies, no image assets.
- Added:
- 2026-08-23
- Dependencies:
- None
- tags
- #block #service #3d #cube #transform #tabs #interactive #motion #no-image #no-dependency
Preview
Service Menu
6つの提供領域を、ひとつの立体で。
設計から運用まで、担当を切らずに一貫して受け持ちます。面を選ぶと、その領域が正面に来ます。
設計・要件定義
目的と読み手を先に言語化し、ページ構成と導線を決めきってから作り始めます。
要件定義書 / サイトマップ
デザイン
事業の温度を保ったまま、読ませて動かすレイアウトへ落とし込みます。
ワイヤー / デザインカンプ
実装・構築
表示速度とアクセシビリティを基準に、壊れにくいコードで組み上げます。
フロント実装 / CMS構築
原稿・撮影
伝わる言葉と写真を用意し、空欄のまま公開しないところまで伴走します。
取材 / ライティング
集客・改善
公開後の数字を読み、直す場所を毎月ひとつに絞って提案します。
解析設計 / 改善提案
保守・運用
更新代行と障害対応。動かし続けるところまでが制作だと考えています。
更新代行 / 監視・バックアップ
01/06
Six Faces
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 立方体6面の切り替え表現(BLK-36)
*
* 6件の内容を「1つの立体の6面」に載せ、選ぶと立方体が回ってその面が正面に来るサービス一覧。
* ライブラリ依存ゼロ・画像素材ゼロで、CSS transform だけで立方体を組んでいる。
*
* 汎用技法メモ(web-design-playbook へ還流する要点)
*
* 1. 立方体は「回してから、自分の法線方向へ押し出す」だけで組める
* 面はすべて absolute inset-0 の同じ正方形で、transform に
* `<回転> translateZ(calc(var(--cube)/2))` を書く。translateZ は回転の *後* に
* 書くので、各面は回転後の自分の法線方向へ半辺ぶん押し出される。
* 前 = translateZ(h) / 右 = rotateY(90deg) / 後 = rotateY(180deg) / 左 = rotateY(-90deg)
* 上 = rotateX(90deg) / 下 = rotateX(-90deg)(いずれも末尾に translateZ(h))
* 辺の長さは CSS 変数 1つ(--cube)に集約する。h を数値でベタ書きすると、
* レスポンシブでサイズを変えた瞬間に立方体が箱でなくなる。
*
* 2. 面を正面に持ってくる回転は「その面の配置回転の逆」
* 上面(rotateX(90deg))を見たいならコンテナを rotateX(-90deg)。
* 面ごとの目標角を配列で持っておけば、選択=コンテナの角度を差し替えるだけになる。
*
* 3. 角度は 0〜360 に丸めず、連続値として貯める(最短回転)
* 5番目→1番目のような遷移で毎回逆回りに大きく回ってしまうのは、角度を
* mod 360 で持っているのが原因。現在値に最も近い等価角へ寄せてから代入する:
* next = target + 360 * Math.round((current - target) / 360)
* これで常に ±180°以内の最短経路になり、何周でも同じ書き方で足せる。
*
* 4. 立方体は「正対」させない
* 正面の面だけを真正面に向けると、平らなカードと区別がつかない。外側にもう1枚
* preserve-3d のラッパを噛ませ、rotateX(-9deg) rotateY(-15deg) の固定の傾きを与えると、
* 隣接する2面が常に見えて立体だと分かる。回転は内側(面の切り替え)と外側(見え方の姿勢)で
* 役割を分けると、両方が干渉しない。
*
* 5. preserve-3d を殺すものを子孫に置かない
* overflow-hidden・filter・backdrop-filter・opacity 未満1 などを持つ要素は強制的に
* flat 化され、子の translateZ が潰れて全面が重なる。クリップしたい装飾(床影など)は
* 3D ツリーの外へ出す。角丸も面には付けない(隣接面との間に隙間が見える)。
*
* 6. 陰影は「面ごとに固定」ではなく「正面かどうか」で出す
* 光源は世界側にあり立方体は回るので、面に固定の濃さを塗ると、その面が正面へ来たとき
* 暗いままになる。各面に墨のオーバーレイを重ね、正面の面だけ opacity-0 にして
* transition-opacity で追従させると、常に正面が明るい箱に見える。あわせて中身は
* 正面以外を opacity-35 まで落とす。側面に回り込んだ文字を読める濃さのまま残すと、
* 傾いた読めない文字列がノイズとして正面の可読性を削る。
*
* 7. prefers-reduced-motion では「遅くする」のではなく 3D をやめる
* 回転そのものが酔いの原因なので、matchMedia で拾って平面モードへ降格させる。
* 面は同じ DOM のまま transform: none で重なり、正面以外を opacity-0 にするだけで
* 「1枚のパネルが差し替わる」表示になる(マークアップも操作も変わらない)。
* 初期状態は 3D 側で描き、判定は useEffect 後に反映するのでハイドレーション差分は出ない。
*
* 8. 見た目が3Dでも、操作はタブのまま
* ナビは role="tablist"(縦)、面は role="tabpanel"。ローミング tabindex +
* 上下左右/Home/End で移動でき、正面以外の面は inert で読み上げとフォーカスから外す。
*
* 着想: CSS transform だけで立方体の6面を作り、面ごとに別のコンテンツを載せるという構造のみ。
* 文言・配色・レイアウト・コードはすべて First CH のオリジナルとして書き起こしている
* (外部コード・素材の取り込みは無し)。
*/
type Face = {
/** 面の配置回転(末尾の translateZ は共通なので持たない) */
place: string;
/** その面を正面に向けるためのコンテナ角度 */
rx: number;
ry: number;
code: string;
en: string;
title: string;
body: string;
deliverable: string;
};
const FACES: Face[] = [
{
place: "rotateY(0deg)",
rx: 0,
ry: 0,
code: "01",
en: "Strategy",
title: "設計・要件定義",
body: "目的と読み手を先に言語化し、ページ構成と導線を決めきってから作り始めます。",
deliverable: "要件定義書 / サイトマップ",
},
{
place: "rotateY(90deg)",
rx: 0,
ry: -90,
code: "02",
en: "Design",
title: "デザイン",
body: "事業の温度を保ったまま、読ませて動かすレイアウトへ落とし込みます。",
deliverable: "ワイヤー / デザインカンプ",
},
{
place: "rotateY(180deg)",
rx: 0,
ry: -180,
code: "03",
en: "Build",
title: "実装・構築",
body: "表示速度とアクセシビリティを基準に、壊れにくいコードで組み上げます。",
deliverable: "フロント実装 / CMS構築",
},
{
place: "rotateY(-90deg)",
rx: 0,
ry: 90,
code: "04",
en: "Content",
title: "原稿・撮影",
body: "伝わる言葉と写真を用意し、空欄のまま公開しないところまで伴走します。",
deliverable: "取材 / ライティング",
},
{
place: "rotateX(90deg)",
rx: -90,
ry: 0,
code: "05",
en: "Growth",
title: "集客・改善",
body: "公開後の数字を読み、直す場所を毎月ひとつに絞って提案します。",
deliverable: "解析設計 / 改善提案",
},
{
place: "rotateX(-90deg)",
rx: 90,
ry: 0,
code: "06",
en: "Support",
title: "保守・運用",
body: "更新代行と障害対応。動かし続けるところまでが制作だと考えています。",
deliverable: "更新代行 / 監視・バックアップ",
},
];
/** 現在値に最も近い等価角へ寄せる(常に最短経路で回す) */
function nearestAngle(current: number, target: number) {
return target + 360 * Math.round((current - target) / 360);
}
export default function SixFacesCube() {
const [active, setActive] = useState(0);
const [rot, setRot] = useState({ rx: 0, ry: 0 });
const [flat, setFlat] = useState(false);
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
// reduced-motion では 3D をやめて平面パネルへ降格(判定はマウント後=SSRと同じ初期描画)
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => setFlat(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
const select = useCallback((index: number) => {
setActive(index);
setRot((prev) => ({
rx: nearestAngle(prev.rx, FACES[index].rx),
ry: nearestAngle(prev.ry, FACES[index].ry),
}));
}, []);
const onTabKeyDown = (e: React.KeyboardEvent) => {
const last = FACES.length - 1;
let next: number | null = null;
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = active === last ? 0 : active + 1;
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") 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();
select(next);
tabsRef.current[next]?.focus();
};
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">
<p
className={`${montserrat.className} text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase`}
>
Service Menu
</p>
<h2 className="mt-3 text-[clamp(1.4rem,3.4vw,2rem)] font-bold tracking-tight text-stone-900">
6つの提供領域を、ひとつの立体で。
</h2>
<p className="mt-3 max-w-lg text-sm leading-[1.9] text-stone-600">
設計から運用まで、担当を切らずに一貫して受け持ちます。面を選ぶと、その領域が正面に来ます。
</p>
</div>
<div className="mt-8 grid gap-6 border-t border-gray-200 @2xl:grid-cols-[1fr_300px] @2xl:gap-0">
{/* ステージ(3Dの外側・床影はここに置く) */}
<div className="relative grid place-items-center overflow-hidden px-6 py-12 [--cube:196px] [perspective:1100px] [perspective-origin:50%_44%] @xl:[--cube:256px] @2xl:py-16">
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-8 left-1/2 h-6 w-[calc(var(--cube)*0.9)] -translate-x-1/2 rounded-[50%] bg-amber-900/15 blur-xl @2xl:bottom-10"
/>
{/* 姿勢用の固定の傾き(面の切り替え回転とは役割を分ける) */}
<div
className="relative [transform-style:preserve-3d]"
style={{ transform: flat ? "none" : "rotateX(-9deg) rotateY(-15deg)" }}
>
{/* 立方体本体(選択した面を正面へ回す) */}
<div
className="relative h-[var(--cube)] w-[var(--cube)] [transform-style:preserve-3d] transition-transform duration-[900ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none"
style={{
transform: flat ? "none" : `rotateX(${rot.rx}deg) rotateY(${rot.ry}deg)`,
}}
>
{FACES.map((face, i) => {
const isActive = i === active;
return (
<div
key={face.code}
id={`cube-face-${face.code}`}
role="tabpanel"
aria-labelledby={`cube-tab-${face.code}`}
inert={!isActive}
className={`absolute inset-0 flex flex-col bg-white p-5 [backface-visibility:hidden] ring-1 ring-stone-900/12 ring-inset transition-opacity duration-500 ease-out motion-reduce:transition-none @xl:p-6 ${
flat && !isActive ? "opacity-0" : "opacity-100"
}`}
style={{
transform: flat
? "none"
: `${face.place} translateZ(calc(var(--cube) / 2))`,
}}
>
{/* 側面に回った文字は「読める密度」で残すとノイズになるので薄める */}
<div
className={`flex h-full flex-col transition-opacity duration-500 ease-out motion-reduce:transition-none ${
isActive ? "opacity-100" : "opacity-35"
}`}
>
<div className="flex items-baseline gap-2">
<span
className={`${montserrat.className} text-[11px] font-bold tracking-[0.18em] text-amber-600 tabular-nums`}
>
{face.code}
</span>
<span
className={`${montserrat.className} ml-auto text-[10px] font-semibold tracking-[0.22em] text-stone-400 uppercase`}
>
{face.en}
</span>
</div>
<span aria-hidden="true" className="mt-3 h-px w-6 bg-amber-600" />
<h3 className="mt-3 text-[15px] font-bold tracking-tight text-stone-900 @xl:text-lg">
{face.title}
</h3>
<p className="mt-2 text-[11px] leading-[1.9] text-stone-600 @xl:text-[12px]">
{face.body}
</p>
<p className="mt-auto border-t border-stone-900/10 pt-3 text-[10px] leading-[1.7] tracking-[0.04em] text-stone-500">
{face.deliverable}
</p>
</div>
{/* 陰影は「正面かどうか」で出す(面に固定の濃さを塗らない) */}
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 bg-stone-900 transition-opacity duration-500 ease-out motion-reduce:transition-none ${
isActive ? "opacity-0" : "opacity-[0.07]"
}`}
/>
</div>
);
})}
</div>
</div>
<p
className={`${montserrat.className} mt-10 text-[11px] font-semibold tracking-[0.2em] text-stone-400 tabular-nums`}
>
{FACES[active].code}
<span className="mx-1.5 text-stone-300">/</span>
{FACES[FACES.length - 1].code}
</p>
</div>
{/* 面の選択(見た目は3Dでも操作はタブ) */}
<div className="border-t border-gray-200 px-6 py-6 @2xl:border-t-0 @2xl:border-l @2xl:px-8 @2xl:py-10">
<p
className={`${montserrat.className} text-[10px] font-bold tracking-[0.22em] text-stone-400 uppercase`}
>
Six Faces
</p>
<div
role="tablist"
aria-label="提供領域"
aria-orientation="vertical"
onKeyDown={onTabKeyDown}
className="mt-3 border-t border-stone-900/10"
>
{FACES.map((face, i) => {
const isActive = i === active;
return (
<button
key={face.code}
ref={(el) => {
tabsRef.current[i] = el;
}}
id={`cube-tab-${face.code}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`cube-face-${face.code}`}
tabIndex={isActive ? 0 : -1}
onClick={() => select(i)}
className="group flex w-full items-center gap-3 border-b border-stone-900/10 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600"
>
<span
className={`${montserrat.className} text-[11px] font-bold tabular-nums ${
isActive ? "text-amber-600" : "text-stone-400"
}`}
>
{face.code}
</span>
<span
className={`text-[13px] font-bold tracking-tight whitespace-nowrap ${
isActive ? "text-stone-900" : "text-stone-500 group-hover:text-stone-900"
}`}
>
{face.title}
</span>
<span
aria-hidden="true"
className={`${montserrat.className} ml-auto text-[10px] font-semibold tracking-[0.16em] whitespace-nowrap uppercase ${
isActive ? "text-stone-500" : "text-stone-300"
}`}
>
{face.en}
</span>
<span
aria-hidden="true"
className={`h-px w-4 shrink-0 origin-right bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
isActive
? "scale-x-100"
: "scale-x-0 group-hover:origin-left group-hover:scale-x-100"
}`}
/>
</button>
);
})}
</div>
</div>
</div>
<div className="border-t border-gray-200 px-6 py-6 @2xl:px-10">
<a
href="#"
className="group inline-flex items-center gap-3 text-sm font-semibold text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
サービスの詳細を見る
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-stone-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
/>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
/>
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/six-faces-cube.json