First CH Designs
BLK-22Blocks

タブ切替×文字分解トランジション

タブを押すと、いま読んでいた文字が上へ抜け、次の文字が行の下から立ち上がってくる「文章そのものが入れ替わる」ブロック。GSAP/SplitTextのDOM実測を捨て、行をデータ(string[])として持つことで、行マスク×文字スタッガーを依存ゼロの純CSSで成立させている。分割は2粒度で、見出しは書記素(Array.from・split("")はサロゲートペアが割れるので不可)、本文は句読点まで含む文節チャンク(1文字ずつinline-blockにすると和文の禁則が効かず行頭に「。」が来るため)。入場は新規マウントでtransitionが走らないのでkeyframes+fill-mode:bothを使い、退場はclassName差し替えでanimation-nameを変えて再生し直す。既存のBLK-13(カード展開が主)との差は、動くのがパネルではなくテキストである点。motion-reduceは自前の@mediaでanimation:none !importantを当て、JS側もout位相ごと飛ばして即時切替に落とす。タブ切替時に本文を文字単位で分解して入れ替える着想を、コード・文言・配色・構成すべてFirst CHのオリジナルとして再構築した(外部コードの取り込みなし・2026-08-12)。

追加日:
2026-08-12
依存:
なし
tags
#block #tabs #split-text #typography #text-transition #interactive #motion #a11y #no-image #no-dependency

プレビュー

Interactive Component / BLK-22

タブ切替×文字分解トランジション

タブを押すと、いま読んでいた文字が上へ抜け、次の文字が行の下から立ち上がってくる。切り替わるのはパネルではなく文章そのもの。

信頼は、静けさに宿る。会社の輪郭を、余白と階層で伝える。情報を足すのではなく、読む順番を設計する。 想定期間: 6–10週。 標準構成: 8–14P。 更新体制: CMS。

"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";

/**
 * タブ切替 × 文字分解トランジション(BLK-22)
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 *
 * 1. SplitText を依存なしで再現する
 *    GSAP/SplitText は「DOMを実測して行に割り、1文字ずつ span で包む」ライブラリだが、
 *    実測は ResizeObserver とレイアウト再計算を呼び込み、SSRでは走らない。
 *    ここでは **行をデータとして持つ**(copy を string[] で書く)ことで実測を捨てている。
 *    行が既知なら「行マスク × 文字スタッガー」は純CSSで成立する。
 *
 * 2. 分割は2粒度に分ける(和文の禁則を壊さないため)
 *    - 見出し = 書記素(Array.from)単位。短い行だけに使い、行ごとに overflow-hidden の
 *      マスクをかけて translateY(110%) から立ち上げる。
 *    - 本文 = 「、」「。」まで含んだ**文節チャンク**単位。1文字ずつ inline-block にすると
 *      隣接するインラインブロック間で改行が起き、行頭に「。」が来る(禁則が効かない)。
 *      折り返しが起きる流し込みテキストは必ずチャンク単位で割る。
 *    - `.split("")` は禁止(サロゲートペアが割れる)。`Intl.Segmenter` も使わない
 *      (Node と ブラウザで ICU の分割結果が変わりうる=ハイドレーション不一致の火種)。
 *
 * 3. 入れ替えは「transition」ではなく「animation」で組む
 *    入場する文字は新規マウントなので、初期状態→最終状態の transition は1フレーム挟まないと
 *    走らない。keyframes + animation-fill-mode: both なら**マウント即再生**される。
 *    退場は className を in→out に差し替えるだけで animation-name が変わり、再生し直される。
 *    位相は out(360ms) → 差し替え → in の2段。動かすのは transform と opacity のみ。
 *
 * 4. reduced-motion は Tailwind の motion-reduce: に頼り切らない
 *    このブロックの keyframes は <style> としてDOM末尾に入る= head のユーティリティより
 *    後勝ちになるため、同じ <style> 内の @media (prefers-reduced-motion: reduce) で
 *    animation: none !important を当てて確実に潰す。JS側も out 位相ごと飛ばして即時切替にする。
 */

type Panel = {
  key: string;
  /** タブのラベル(和文・5字まで。等幅グリッドなので長いと折り返す) */
  label: string;
  /** 左レールの縦組みラベル */
  en: string;
  /** 見出し。1要素=1行・8字まで(折り返すと行マスクが破綻する) */
  headline: string[];
  /** 本文。1要素=1チャンク(句読点まで含める) */
  body: string[];
  meta: { label: string; value: string }[];
};

const PANELS: Panel[] = [
  {
    key: "corporate",
    label: "企業サイト",
    en: "Corporate",
    headline: ["信頼は、", "静けさに宿る。"],
    body: [
      "会社の輪郭を、",
      "余白と階層で伝える。",
      "情報を足すのではなく、",
      "読む順番を設計する。",
    ],
    meta: [
      { label: "想定期間", value: "6–10週" },
      { label: "標準構成", value: "8–14P" },
      { label: "更新体制", value: "CMS" },
    ],
  },
  {
    key: "recruit",
    label: "採用サイト",
    en: "Recruit",
    headline: ["採用は、", "会社の自己紹介。"],
    body: [
      "働く人の言葉を主役に、",
      "写真と余白で温度を出す。",
      "応募までの導線は、",
      "最短距離で結ぶ。",
    ],
    meta: [
      { label: "想定期間", value: "5–8週" },
      { label: "主要導線", value: "応募フォーム" },
      { label: "更新体制", value: "求人CMS" },
    ],
  },
  {
    key: "landing",
    label: "LP・特設",
    en: "Landing",
    headline: ["1画面で、", "迷わせない。"],
    body: [
      "読み進める理由を、",
      "セクションごとに置く。",
      "計測と改善を前提に、",
      "運用できる型で渡す。",
    ],
    meta: [
      { label: "想定期間", value: "3–5週" },
      { label: "検証", value: "A/B前提" },
      { label: "納品", value: "計測込み" },
    ],
  },
];

/** 退場位相の長さ(keyframes 260ms + 最大ディレイ 90ms に少し余裕) */
const OUT_MS = 360;

/** 書記素分割。`.split("")` はサロゲートペアを割るので使わない */
const graphemes = (s: string) => Array.from(s);

export default function TextSwapTabs() {
  const uid = useId();
  const [tab, setTab] = useState(0); // タブUI(即時に追従させる)
  const [shown, setShown] = useState(0); // 表示中の本文(out位相のあいだ遅れる)
  const [phase, setPhase] = useState<"in" | "out">("in");
  const [seq, setSeq] = useState(0); // ワイプ再生用のカウンタ
  const tabRef = useRef(0);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const select = useCallback(
    (next: number) => {
      if (next === tabRef.current) return;
      tabRef.current = next;
      setTab(next);
      setSeq((n) => n + 1);
      const reduced =
        typeof window !== "undefined" &&
        window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      if (reduced) {
        setShown(next);
        setPhase("in");
        return;
      }
      setPhase("out");
    },
    [],
  );

  // out位相が終わったら本文を差し替えて in位相へ(最新のタブは ref から読む)
  useEffect(() => {
    if (phase !== "out") return;
    const id = window.setTimeout(() => {
      setShown(tabRef.current);
      setPhase("in");
    }, OUT_MS);
    return () => window.clearTimeout(id);
  }, [phase]);

  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    const last = PANELS.length - 1;
    let next = -1;
    if (e.key === "ArrowRight") next = tab === last ? 0 : tab + 1;
    else if (e.key === "ArrowLeft") next = tab === 0 ? last : tab - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    if (next < 0) return;
    e.preventDefault();
    select(next);
    btnRefs.current[next]?.focus();
  };

  const panel = PANELS[shown];
  const rise = phase === "in" ? "blk22-rise" : "blk22-riseOut";
  const fade = phase === "in" ? "blk22-fade" : "blk22-fadeOut";
  const plain =
    panel.headline.join("") +
    panel.body.join("") +
    panel.meta.map((m) => ` ${m.label}: ${m.value}。`).join("");

  // 見出しは2行にまたがって連番でスタッガーさせるので、行ごとの開始インデックスを先に出す
  const headLines = panel.headline.map(graphemes);
  const lineOffsets = headLines.map((line, i) =>
    headLines.slice(0, i).reduce((n, prev) => n + prev.length, 0),
  );

  return (
    <section
      aria-labelledby={`${uid}-title`}
      className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      <header className="px-5 pt-8 sm:px-10 sm:pt-10">
        <p className="font-display text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
          Interactive Component / BLK-22
        </p>
        <h2
          id={`${uid}-title`}
          className="mt-3 text-lg font-bold tracking-tight text-gray-900 sm:text-xl"
        >
          タブ切替×文字分解トランジション
        </h2>
        <p className="mt-3 max-w-lg text-[13px] leading-[1.95] text-gray-600">
          タブを押すと、いま読んでいた文字が上へ抜け、次の文字が行の下から立ち上がってくる。切り替わるのはパネルではなく文章そのもの。
        </p>
      </header>

      <div className="mt-7 px-5 sm:px-10">
        <div
          role="tablist"
          aria-label="対応領域"
          className="relative grid grid-cols-3 border-b border-gray-200"
        >
          {PANELS.map((p, i) => (
            <button
              key={p.key}
              type="button"
              role="tab"
              id={`${uid}-tab-${i}`}
              aria-selected={tab === i}
              aria-controls={`${uid}-panel`}
              tabIndex={tab === i ? 0 : -1}
              ref={(el) => {
                btnRefs.current[i] = el;
              }}
              onClick={() => select(i)}
              onKeyDown={onKeyDown}
              className={`pb-3 text-xs font-semibold tracking-[0.02em] whitespace-nowrap transition-colors duration-300 outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none sm:text-sm sm:tracking-[0.04em] ${
                tab === i
                  ? "text-gray-900"
                  : "text-gray-400 hover:text-gray-700"
              }`}
            >
              {p.label}
            </button>
          ))}
          {/* 等幅タブなので計測は不要。translateX(index * 100%) だけで下線が合う */}
          <span
            aria-hidden="true"
            className="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
        role="tabpanel"
        id={`${uid}-panel`}
        aria-labelledby={`${uid}-tab-${shown}`}
        tabIndex={0}
        className="relative px-5 py-8 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 sm:px-10 sm:py-10"
      >
        {/* 切替のたびに左→右→消滅で走るアンバーのワイプ(key の付け替えで再生) */}
        <span
          key={`wipe-${seq}`}
          aria-hidden="true"
          className="blk22-wipe pointer-events-none absolute top-0 left-0 h-px w-full bg-amber-600 motion-reduce:animate-none"
        />

        {/* 分解した文字は支援技術に読ませず、素の文章を別途置く */}
        <p className="sr-only">{plain}</p>

        <div
          key={`panel-${shown}`}
          aria-hidden="true"
          className="grid min-h-[286px] grid-cols-[auto_1fr] gap-5 sm:min-h-[248px] sm:gap-9"
        >
          {/* 左レール(標本の目盛り。カードで囲わずhairlineで区切る) */}
          <div className="flex flex-col items-center gap-3 pt-1">
            <span
              className={`${fade} font-display text-[11px] font-bold text-amber-600 tabular-nums motion-reduce:animate-none`}
              style={{ animationDelay: phase === "in" ? "40ms" : "0ms" }}
            >
              {String(shown + 1).padStart(2, "0")}
            </span>
            <span className="relative w-px flex-1 overflow-hidden bg-gray-200">
              <span
                className="absolute inset-0 block origin-top bg-amber-600 transition-transform duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
                style={{ transform: `scaleY(${(shown + 1) / PANELS.length})` }}
              />
            </span>
            <span
              className={`${fade} font-display text-[10px] font-semibold tracking-[0.3em] text-gray-400 uppercase [writing-mode:vertical-rl] motion-reduce:animate-none`}
              style={{ animationDelay: phase === "in" ? "120ms" : "10ms" }}
            >
              {panel.en}
            </span>
          </div>

          <div className="min-w-0">
            {/* 見出し: 1行=1マスク。文字は行の下から立ち上がる */}
            <h3 className="text-[clamp(1.25rem,4vw,2.15rem)] leading-[1.5] font-bold tracking-tight text-gray-900">
              {headLines.map((chars, li) => (
                <span key={li} className="block overflow-hidden">
                  {chars.map((ch, ci) => {
                    const k = lineOffsets[li] + ci;
                    return (
                      <span
                        key={ci}
                        className={`${rise} inline-block motion-reduce:animate-none`}
                        style={{
                          animationDelay:
                            phase === "in"
                              ? `${80 + k * 16}ms`
                              : `${Math.min(k * 4, 60)}ms`,
                        }}
                      >
                        {ch}
                      </span>
                    );
                  })}
                </span>
              ))}
            </h3>

            {/* 本文: 文節チャンク単位。折り返しはチャンクの境目でしか起きない=禁則が保たれる */}
            <p className="mt-6 max-w-md text-[13px] leading-[2] text-gray-600 sm:text-sm">
              {panel.body.map((chunk, i) => (
                <span
                  key={i}
                  className={`${fade} inline-block motion-reduce:animate-none`}
                  style={{
                    animationDelay:
                      phase === "in" ? `${300 + i * 36}ms` : `${30 + i * 10}ms`,
                  }}
                >
                  {chunk}
                </span>
              ))}
            </p>

            {/* hairlineでグルーピングしたメタ行 */}
            <dl className="mt-8 flex flex-wrap items-baseline gap-x-8 gap-y-3 border-t border-gray-200 pt-5">
              {panel.meta.map((m, i) => (
                <div
                  key={m.label}
                  className={`${fade} flex items-baseline gap-3 motion-reduce:animate-none`}
                  style={{
                    animationDelay:
                      phase === "in" ? `${470 + i * 80}ms` : `${60 + i * 8}ms`,
                  }}
                >
                  <dt className="text-[10px] font-bold tracking-[0.22em] text-gray-400">
                    {m.label}
                  </dt>
                  <dd className="font-display text-sm font-bold text-gray-900 tabular-nums">
                    {m.value}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
        </div>
      </div>

      <style>{`
        @keyframes blk22-rise {
          from { opacity: 0; transform: translateY(110%); }
          to   { opacity: 1; transform: none; }
        }
        @keyframes blk22-riseOut {
          from { opacity: 1; transform: none; }
          to   { opacity: 0; transform: translateY(-110%); }
        }
        @keyframes blk22-fade {
          from { opacity: 0; transform: translateY(0.6em); }
          to   { opacity: 1; transform: none; }
        }
        @keyframes blk22-fadeOut {
          from { opacity: 1; transform: none; }
          to   { opacity: 0; transform: translateY(-0.35em); }
        }
        @keyframes blk22-wipe {
          0%      { transform: scaleX(0); transform-origin: left center; }
          48%     { transform: scaleX(1); transform-origin: left center; }
          52%     { transform: scaleX(1); transform-origin: right center; }
          100%    { transform: scaleX(0); transform-origin: right center; }
        }
        .blk22-rise    { animation: blk22-rise 620ms cubic-bezier(0.16, 1, 0.3, 1) both; }
        .blk22-riseOut { animation: blk22-riseOut 260ms cubic-bezier(0.7, 0, 0.84, 0) both; }
        .blk22-fade    { animation: blk22-fade 620ms cubic-bezier(0.16, 1, 0.3, 1) both; }
        .blk22-fadeOut { animation: blk22-fadeOut 240ms cubic-bezier(0.7, 0, 0.84, 0) both; }
        .blk22-wipe    { animation: blk22-wipe 620ms cubic-bezier(0.65, 0, 0.35, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .blk22-rise,
          .blk22-riseOut,
          .blk22-fade,
          .blk22-fadeOut,
          .blk22-wipe { animation: none !important; }
        }
      `}</style>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/text-swap-tabs.json