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