First CH Designs
BLK-36Blocks

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つの提供領域を、ひとつの立体で。

設計から運用まで、担当を切らずに一貫して受け持ちます。面を選ぶと、その領域が正面に来ます。

01Strategy

設計・要件定義

目的と読み手を先に言語化し、ページ構成と導線を決めきってから作り始めます。

要件定義書 / サイトマップ

02Design

デザイン

事業の温度を保ったまま、読ませて動かすレイアウトへ落とし込みます。

ワイヤー / デザインカンプ

03Build

実装・構築

表示速度とアクセシビリティを基準に、壊れにくいコードで組み上げます。

フロント実装 / CMS構築

04Content

原稿・撮影

伝わる言葉と写真を用意し、空欄のまま公開しないところまで伴走します。

取材 / ライティング

05Growth

集客・改善

公開後の数字を読み、直す場所を毎月ひとつに絞って提案します。

解析設計 / 改善提案

06Support

保守・運用

更新代行と障害対応。動かし続けるところまでが制作だと考えています。

更新代行 / 監視・バックアップ

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