First CH Designs
BLK-13Blocks

インタラクティブ・カード&タブモジュール

タブで切り替え、パネルを開くと隣が背表紙だけ残して畳まれる「重なりアコーディオン」。全パネルを展開時と同じサイズで絶対配置しz-indexを昇順にすることで、width/flex-growを一切アニメーションせず translate だけで開閉する(レイアウト再計算ゼロ・依存ライブラリゼロ)。位置は比率のcalcで表現するためResizeObserverもmatchMediaも不要で、縦横の切替はコンテナクエリで純CSS。狭幅では縦アコーディオン、@2xl以上で横アコーディオンに変わる。カード展開+タブ切替という着想を、コード・文言・配色・構成すべてFirst CHのオリジナルとして再構築した(外部コードの取り込みなし・2026-08-07)。

追加日:
2026-08-07
依存:
なし
tags
#block #accordion #tabs #interactive #motion #container-query #no-image #no-dependency

プレビュー

Specimen BLK-13

関わり方は、3つあります。

着手前に文章で合意し、公開後は手順書で引き渡すまでを一続きで担当します。

目的から逆算して情報を決める

誰に何をしてほしいページなのかを先に言葉にし、そこから載せる情報と導線を決めます。構成と計測の設計まで、実装前に文章で合意します。

目安1〜2週間
"use client";

import { useId, useRef, useState, type CSSProperties } from "react";

/**
 * インタラクティブ・カード&タブモジュール(BLK-13)
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点)
 *
 * 1. 「重なりアコーディオン」— width / flex-grow を一切アニメーションしない開閉
 *    全パネルを絶対配置+同一サイズ(=展開時のサイズ)にし、z-index を index 昇順にする。
 *    開閉は各パネルの translate だけで表現し、閉じたパネルは「次のパネルに覆われて背表紙
 *    (spine)だけが見えている」状態として成立させる。レイアウト再計算が起きないので
 *    transform / opacity のみでヌルヌル動き、reduced-motion も transition を切るだけで済む。
 *
 *      offsetRatio(i) = i <= active ?  i * SPINE
 *                                   :  1 - (N - i) * SPINE      (SPINE = 背表紙 / 全体)
 *
 *    active の右端と active+1 の左端がちょうど一致するので、隙間も重なりズレも出ない。
 *
 * 2. 「%は自分自身の寸法基準」— translate の % は親ではなく要素自身のサイズに掛かる。
 *    そこで背表紙の幅を「全体に対する比率」で決めておくと、パネル幅も比率で決まり、
 *    translate 値を「パネル幅に対する%」へ機械的に換算できる(下の toPct)。
 *    結果、ResizeObserver も matchMedia も使わず完全に流動幅で成立する。
 *
 * 3. 「軸の切り替えは CSS 変数で」— 縦横の切り替えを JS で判定するとハイドレーション不一致と
 *    初回フラッシュを招く。インライン style で --ax / --ay を渡し、クラス側で
 *    [transform:translateY(var(--ay))] @2xl:[transform:translateX(var(--ax))] と書けば
 *    ブレークポイントは純CSSのまま。ビューポートではなく親の幅で切り替えたいので
 *    コンテナクエリ(@container + @2xl:)を使う。
 *
 * 4. 「タブの下線は計測しない」— タブを等幅(grid-cols-N)にすると、下線バーの幅を 1/N に
 *    しておくだけで translateX(index * 100%) がそのまま正しい位置になる。
 *
 * 着想: 「カード展開+タブ切替」という着想。
 * 実装・文言・配色・構成はすべて First CH のオリジナル(外部コードの取り込みは無し)。
 */

const N = 4;

/** 背表紙の比率(全体を 1 としたときの幅/高さ) */
const SPINE_X = 1 / 12; // 横アコーディオン(@2xl 以上)
const SPINE_Y = 1 / 9; // 縦アコーディオン(狭幅)

const CARD_X = 1 - (N - 1) * SPINE_X; // = 0.75
const CARD_Y = 1 - (N - 1) * SPINE_Y; // = 0.6667

/** 全体基準のオフセット比率 → パネル自身の寸法に対する%(translate 用) */
const toPct = (i: number, active: number, spine: number, card: number) => {
  const ratio = i <= active ? i * spine : 1 - (N - i) * spine;
  return `${((ratio / card) * 100).toFixed(4)}%`;
};

const Z = ["z-10", "z-20", "z-30", "z-40"];

type Panel = {
  no: string;
  spine: string;
  title: string;
  body: string;
  metaLabel: string;
  metaValue: string;
};

type Tab = {
  key: string;
  label: string;
  lead: string;
  panels: Panel[];
};

const TABS: Tab[] = [
  {
    key: "production",
    label: "制作",
    lead: "着手前に文章で合意し、公開後は手順書で引き渡すまでを一続きで担当します。",
    panels: [
      {
        no: "01",
        spine: "設計",
        title: "目的から逆算して情報を決める",
        body: "誰に何をしてほしいページなのかを先に言葉にし、そこから載せる情報と導線を決めます。構成と計測の設計まで、実装前に文章で合意します。",
        metaLabel: "目安",
        metaValue: "1〜2週間",
      },
      {
        no: "02",
        spine: "設計図",
        title: "方向性を3案に落として選ぶ",
        body: "参考サイトを分解し「何を借りるのか」を役割ごとに切り分け、互いに異なる3案を提示します。色と写真を差し替えても成立する骨格を先に固めます。",
        metaLabel: "目安",
        metaValue: "2週間",
      },
      {
        no: "03",
        spine: "実装",
        title: "実機の幅で見ながら直す",
        body: "375/768/1280 の3幅で撮り、横スクロールとコントラストを数値で確認しながら組みます。動きは transform と opacity に限定します。",
        metaLabel: "目安",
        metaValue: "2〜3週間",
      },
      {
        no: "04",
        spine: "公開",
        title: "編集できる範囲を明示して渡す",
        body: "管理画面から触れる範囲と触れない範囲を一覧にし、更新手順を文章と動画で残します。公開から1か月は初期不具合の修正を無償で対応します。",
        metaLabel: "目安",
        metaValue: "1週間",
      },
    ],
  },
  {
    key: "operation",
    label: "運用",
    lead: "つくって終わりにせず、数字を見ながら小さく直し続ける体制を月額で用意します。",
    panels: [
      {
        no: "01",
        spine: "更新",
        title: "止めないための更新代行",
        body: "お知らせ・実績・料金などの差し替えを代行します。ご自身で更新される箇所は編集マニュアルを整え、迷ったときに読む場所を1か所に集約します。",
        metaLabel: "対応",
        metaValue: "月2回まで",
      },
      {
        no: "02",
        spine: "計測",
        title: "見るべき数字を3つに絞る",
        body: "全指標を並べても判断は進みません。流入・到達・問い合わせの3点に絞り、先月比と季節要因を添えた1枚のレポートにまとめます。",
        metaLabel: "頻度",
        metaValue: "毎月",
      },
      {
        no: "03",
        spine: "改善",
        title: "仮説を1つずつ検証する",
        body: "変更は一度に1か所だけ入れ、効いたかどうかを翌月のレポートで答え合わせします。うまくいかなかった施策も記録に残し、同じ道を二度通りません。",
        metaLabel: "単位",
        metaValue: "月1施策",
      },
      {
        no: "04",
        spine: "保守",
        title: "壊れる前に手当てする",
        body: "CMSとプラグインの更新、バックアップ、SSLと表示速度の点検を定期で実施します。障害時は状況・原因・復旧見込みを最初の連絡で揃えて報告します。",
        metaLabel: "点検",
        metaValue: "毎月/随時",
      },
    ],
  },
  {
    key: "ai",
    label: "AI活用",
    lead: "道具を増やす前に、いまの業務のどこが詰まっているのかを一緒に見つけるところから始めます。",
    panels: [
      {
        no: "01",
        spine: "棚卸し",
        title: "詰まっている工程を特定する",
        body: "1日の業務を工程に割り、時間のかかる作業と属人化している作業を洗い出します。ここで「AIを使わない」という結論になることもあります。",
        metaLabel: "形式",
        metaValue: "90分×2回",
      },
      {
        no: "02",
        spine: "設計",
        title: "任せる範囲と人が見る箇所を決める",
        body: "生成物をそのまま外へ出さないための確認手順を先に決めます。判断を伴う工程は人が持ち、下書きと整形をAIへ寄せる線引きを業務ごとに引きます。",
        metaLabel: "成果物",
        metaValue: "診断レポート",
      },
      {
        no: "03",
        spine: "試行",
        title: "小さく試して効果を測る",
        body: "対象を1業務に絞って2週間試し、所要時間と手戻りの回数を導入前後で比較します。効果が出なければ広げずに引き返す前提で設計します。",
        metaLabel: "期間",
        metaValue: "2週間",
      },
      {
        no: "04",
        spine: "定着",
        title: "手順を残して自走できる形にする",
        body: "使い方を口伝えにせず、プロンプトと確認手順を社内ドキュメントとして残します。月次の壁打ちで運用の詰まりを解き、伴走の頻度を段階的に下げます。",
        metaLabel: "伴走",
        metaValue: "月額/随時",
      },
    ],
  },
];

export default function OverlapAccordionCards() {
  const uid = useId();
  const [tab, setTab] = useState(0);
  const [active, setActive] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const selectTab = (i: number) => {
    setTab(i);
    setActive(0);
  };

  // タブは WAI-ARIA の roving tabindex(矢印/Home/End で移動し、移動先を選択)
  const onTabKeyDown = (e: React.KeyboardEvent) => {
    const map: Record<string, number> = {
      ArrowRight: (tab + 1) % TABS.length,
      ArrowLeft: (tab - 1 + TABS.length) % TABS.length,
      Home: 0,
      End: TABS.length - 1,
    };
    const next = map[e.key];
    if (next === undefined) return;
    e.preventDefault();
    selectTab(next);
    tabRefs.current[next]?.focus();
  };

  const current = TABS[tab];

  return (
    <div 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="text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
          Specimen BLK-13
        </p>
        <h2 className="font-display mt-3 text-[clamp(1.5rem,3.4vw,2rem)] leading-tight font-bold tracking-tight text-gray-900">
          関わり方は、3つあります。
        </h2>
        <p className="mt-3 max-w-lg text-sm leading-[1.9] text-gray-600">{current.lead}</p>
      </div>

      {/* タブ(等幅なので下線バーは translateX(index*100%) だけで合う) */}
      <div className="mt-7 px-6 @2xl:px-10">
        <div
          role="tablist"
          aria-label="関わり方"
          onKeyDown={onTabKeyDown}
          className="relative grid grid-cols-3 border-b border-gray-200"
        >
          {TABS.map((t, i) => (
            <button
              key={t.key}
              ref={(el) => {
                tabRefs.current[i] = el;
              }}
              type="button"
              role="tab"
              id={`${uid}-tab-${i}`}
              aria-selected={i === tab}
              aria-controls={`${uid}-panel-${i}`}
              tabIndex={i === tab ? 0 : -1}
              onClick={() => selectTab(i)}
              className={`cursor-pointer pb-3 text-sm font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                i === tab ? "text-gray-900" : "text-gray-400 hover:text-gray-700"
              }`}
            >
              {t.label}
            </button>
          ))}
          <span
            aria-hidden="true"
            className="pointer-events-none absolute -bottom-px left-0 h-0.5 w-1/3 bg-amber-600 transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
            style={{ transform: `translateX(${tab * 100}%)` }}
          />
        </div>
      </div>

      {/* 重なりアコーディオン本体 */}
      <div className="px-6 pt-8 pb-8 @2xl:px-10 @2xl:pt-10 @2xl:pb-10">
        {TABS.map((t, i) => (
          <div
            key={t.key}
            role="tabpanel"
            id={`${uid}-panel-${i}`}
            aria-labelledby={`${uid}-tab-${i}`}
            hidden={i !== tab}
          >
            {/* key に tab を混ぜて再マウントさせ、切替のたびに入場アニメを走らせる
                (state + effect を使わずに済ませるための常套手段) */}
            <div
              key={`${t.key}-${tab}`}
              className="relative h-[600px] animate-[blk13-enter_500ms_ease-out_both] overflow-hidden border border-gray-200 @2xl:h-[380px] motion-reduce:animate-none"
            >
              {t.panels.map((p, j) => {
                const open = j === active;
                return (
                  <div
                    key={p.no}
                    style={
                      {
                        "--ay": toPct(j, active, SPINE_Y, CARD_Y),
                        "--ax": toPct(j, active, SPINE_X, CARD_X),
                      } as CSSProperties
                    }
                    className={`absolute top-0 left-0 flex h-[66.6667%] w-full flex-col overflow-hidden border-t border-gray-200 bg-white transition-transform duration-[600ms] ease-[cubic-bezier(0.16,1,0.3,1)] [transform:translateY(var(--ay))] @2xl:h-full @2xl:w-[75%] @2xl:flex-row @2xl:border-t-0 @2xl:border-l @2xl:[transform:translateX(var(--ax))] motion-reduce:transition-none ${Z[j]} focus-within:z-50`}
                  >
                    {/* 背表紙=閉じているときに見えている部分。そのまま開閉ボタン */}
                    <button
                      type="button"
                      onClick={() => setActive(j)}
                      aria-expanded={open}
                      aria-controls={`${uid}-body-${i}-${j}`}
                      className={`group flex h-[16.6667%] w-full shrink-0 cursor-pointer items-center gap-3 border-r border-gray-200 px-4 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 @2xl:h-full @2xl:w-[11.1111%] @2xl:flex-col @2xl:items-center @2xl:justify-between @2xl:px-0 @2xl:py-6 ${
                        open ? "bg-white" : "bg-gray-50/70 hover:bg-amber-50/60"
                      } transition-colors duration-300 motion-reduce:transition-none`}
                    >
                      <span
                        className={`font-display text-xs font-bold tracking-[0.18em] tabular-nums ${
                          open ? "text-amber-600" : "text-gray-400"
                        }`}
                      >
                        {p.no}
                      </span>
                      <span
                        className={`text-[13px] font-semibold tracking-[0.14em] @2xl:[writing-mode:vertical-rl] ${
                          open ? "text-gray-900" : "text-gray-500"
                        }`}
                      >
                        {p.spine}
                      </span>
                      <span
                        aria-hidden="true"
                        className={`ml-auto h-px w-5 bg-amber-600 transition-transform duration-300 ease-out @2xl:ml-0 @2xl:h-5 @2xl:w-px motion-reduce:transition-none ${
                          open
                            ? "origin-left scale-x-100 @2xl:origin-top @2xl:scale-y-100"
                            : "origin-left scale-x-0 group-hover:scale-x-100 @2xl:origin-top @2xl:scale-x-100 @2xl:scale-y-0 @2xl:group-hover:scale-y-100"
                        }`}
                      />
                    </button>

                    {/* 本文。閉じているときは他パネルに覆われるので支援技術からも隠す */}
                    <div
                      id={`${uid}-body-${i}-${j}`}
                      aria-hidden={!open}
                      className={`flex min-w-0 flex-1 flex-col justify-center px-4 py-5 transition-opacity duration-500 ease-out @2xl:px-9 @2xl:py-8 motion-reduce:transition-none ${
                        open ? "opacity-100" : "pointer-events-none opacity-0"
                      }`}
                    >
                      <h3 className="font-display text-lg leading-snug font-bold tracking-tight text-gray-900 @2xl:text-xl">
                        {p.title}
                      </h3>
                      <p className="mt-4 max-w-md text-[13px] leading-[1.85] text-gray-600 @2xl:text-sm @2xl:leading-[1.95]">
                        {p.body}
                      </p>
                      <div className="mt-5 flex max-w-md items-baseline gap-4 border-t border-gray-200 pt-4 @2xl:mt-6">
                        <span className="text-[10px] font-bold tracking-[0.22em] text-gray-400">
                          {p.metaLabel}
                        </span>
                        <span className="font-display text-sm font-bold tracking-tight text-gray-900">
                          {p.metaValue}
                        </span>
                      </div>
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
        ))}
      </div>

      {/* 入場アニメの keyframes(このブロック内で完結させる) */}
      <style>{`
        @keyframes blk13-enter {
          from { opacity: 0; transform: translateY(10px); }
          to   { opacity: 1; transform: none; }
        }
      `}</style>
    </div>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/overlap-accordion-cards.json