BLK-104Blocks

読み込み中のスケルトン表示(本物と寸法を揃えて配置のずれを防ぐ)

データを待つあいだに、本物と同じ寸法の灰色の面(スケルトンスクリーン)を置くブロックです。記事一覧・プロフィール・表の3枠を同時に読み込み、届いた枠から本物へ差し替えます。画像は縦横比、文字は行の高さと行数を先に決めておくので、画面幅が変わっても差し替えで高さが変わりません。各枠の下端に差し替え前後の高さとその差をこの画面で実測して表示し、「寸法を確保しない」に切り替えると、同じ条件でずれが何px出るかを比べられます。読み込みの時間(150/700/1800ms)と、これより速ければ骨格を出さない閾値(初期値300ms)を切り替えられ、速い回は灰色が一瞬だけ点滅することがありません。表の見出し行や数値の項目名は読み込み前から本物を出し、骨格は値の部分だけにしています。骨格の面は aria-hidden、待っている枠は aria-busy にし、読み込み中であることは枠の外の通知で1回だけ読み上げます。動きの軽減を選んだ環境では光の帯を止め、静止した面と文字で状態が分かります。キーボードだけで操作でき、角丸ゼロ・画像素材ゼロ・依存ゼロです。

追加日:
2026-10-22
依存:
なし
tags
#block #skeleton #skeleton-screen #loading #placeholder #layout-shift #cls #aria-busy #aspect-ratio #table #a11y #reduced-motion #hairline #no-image #no-dependency

プレビュー

読み込み中も配置が動かないスケルトン表示

BLK-104

データを待つあいだ、本物と同じ寸法の灰色の面を置いておくと、読み込み後に差し替えても下の要素が押し下げられません。下の3枠は同時に読み込みを始め、終わった枠から順に本物へ差し替わります。各枠の下端に差し替え前後の高さと、その差(ずれ)をこの画面で実測して表示しています。

読み込みにかかる時間
これより速ければ骨格を出さない

各枠が届く時刻と閾値

0ms2500ms
  • 閾値より遅い枠だけ骨格を出す
  • 閾値より速い枠は寸法だけ確保して、そのまま本物を出す

01

記事一覧

待機中(骨格なし)/1800ms

枠の高さ0px → 0pxずれ 0px

02

担当者のプロフィール

待機中(骨格なし)/1260ms
担当サイト
平均の返信
在籍

枠の高さ0px → 0pxずれ 0px

03

依頼の一覧表

待機中(骨格なし)/2430ms
受付日依頼の内容担当状態見積額(税抜)

枠の高さ0px → 0pxずれ 0px

ずれの値は、この画面と上の条件で測った枠の高さの差です。フォントの読み込みや広告の差し込みなど、ほかの要因によるずれは含みません。

"use client";

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

/**
 * 読み込み中のスケルトン表示(実際の配置と寸法を揃える)
 *
 * 差別化: 既存の空状態ブロックは「データが無い」画面、本ブロックは「データを待っている」画面を扱う。
 * 記事一覧・プロフィール・表の3枠を同時に読み込み、差し替え前後の高さの差を各枠の下端で実測して並べる。
 * 英字の小見出し・句点で終わる見出し・末尾の矢印リンク・角丸・スクロールでの出現アニメは使わない。
 *
 * 汎用技法メモ:
 * 1. **骨格は本物と同じ箱で組む**。文字の骨格は「行の高さ=箱の高さ」にして中に細いバーを置き、
 *    本物の文字側も leading を px で固定・line-clamp と min-h で行数を決める。画像は aspect-ratio で先に面積を取る。
 *    画面幅が変わっても行数と縦横比が同じなので、差し替えで高さが変わらない。
 * 2. **先に分かるものは先に出す**。表の見出し行・数値の項目名は読み込みを待たずに本物を描き、骨格は値の部分だけにする。
 * 3. **閾値より速ければ骨格を出さない**。閾値(初期値 300ms)までは寸法だけ確保した空白を置き、
 *    それを過ぎても終わらない枠だけ骨格に切り替える。速い通信で灰色が一瞬だけ点滅するのを防ぐ。
 * 4. **読み上げは領域の外で1回**。骨格の面は aria-hidden、待っている枠は aria-busy="true"。
 *    「読み込んでいます」は aria-busy の外に置いた role="status" へ1回だけ入れ、3枠ぶん繰り返さない。
 * 5. **ずれは ResizeObserver で測る**。差し替え前の高さを控え、読み込み後の高さの変化量を足し上げて枠の下端に出す。
 *    幅が変わったときの変化は差し替えのずれではないので数えず、そこで基準を取り直す。
 *    比較用に「寸法を確保しない」を選ぶと、1行の「読み込み中…」と寸法なしの画像で同じ計測をする。
 * 6. 光の帯は ::after の transform だけで流す。prefers-reduced-motion では帯と切り替えのフェードを止め、
 *    静止した灰色の面と各枠の「読み込み中」の文字で状態が読める。
 *
 * 文言・人名・数値はすべて First CH のオリジナル(架空)。
 */

type PanelKey = "articles" | "profile" | "table";
type Phase = "waiting" | "skeleton" | "loaded";
type Phases = Record<PanelKey, Phase>;

const PANELS: { key: PanelKey; no: string; label: string; factor: number }[] = [
  { key: "articles", no: "01", label: "記事一覧", factor: 1 },
  { key: "profile", no: "02", label: "担当者のプロフィール", factor: 0.7 },
  { key: "table", no: "03", label: "依頼の一覧表", factor: 1.35 },
];

const SPEEDS = [
  { ms: 150, label: "速い" },
  { ms: 700, label: "普通" },
  { ms: 1800, label: "遅い" },
] as const;
const THRESHOLDS = [0, 300, 800] as const;
const AXIS_MS = 2500;
const IMAGE_DELAY = 260; // 寸法を確保しないとき、画像が後から届くまでの時間

const ALL_WAITING: Phases = { articles: "waiting", profile: "waiting", table: "waiting" };
const NO_IMAGES: Record<PanelKey, boolean> = { articles: false, profile: false, table: false };

const loadMs = (speed: number, factor: number) => Math.round(speed * factor);

const ARTICLES = [
  {
    tag: "制作",
    date: "2026.10.14",
    title: "スマートフォンで本文を読みやすくする行間と文字サイズの決め方",
    summary: "本文16px・行間1.8を起点に、見出しとの差を3段階で決めます。",
    tone: "repeating-linear-gradient(135deg,#fde68a 0 10px,#fef3c7 10px 20px)",
  },
  {
    tag: "運用",
    date: "2026.10.08",
    title: "更新の依頼から公開までの日数を短くする段取り",
    summary: "依頼票の項目を5つに絞り、確認の往復を1回で済ませます。",
    tone: "linear-gradient(90deg,#e5e7eb 0 50%,#f3f4f6 50%),linear-gradient(#fff,#fff)",
  },
  {
    tag: "計測",
    date: "2026.09.29",
    title: "問い合わせフォームで離脱が多い項目を見つける計測の設定",
    summary: "入力欄ごとの到達数を取り、途中で止まる項目を特定します。",
    tone: "radial-gradient(circle at 30% 40%,#fcd34d 0 22%,transparent 23%),linear-gradient(#fffbeb,#fffbeb)",
  },
];

const PROFILE = {
  initial: "佐",
  name: "佐倉 みのり",
  role: "Webディレクター/更新運用の窓口",
  bio: "公開後の更新依頼を受け付け、作業の切り分けと日程の調整を担当しています。写真の差し替えや料金表の改定など、小さな変更ほど早く反映できるよう、依頼票の書き方からご案内します。",
  stats: [
    { label: "担当サイト", value: "24", unit: "件" },
    { label: "平均の返信", value: "4", unit: "時間" },
    { label: "在籍", value: "6", unit: "年" },
  ],
};

const ROWS = [
  { date: "10/21", item: "料金表の改定と注記の追加", owner: "佐倉", status: "見積中", amount: "¥38,000" },
  { date: "10/19", item: "採用ページへの募集職種の追加", owner: "高見", status: "作業中", amount: "¥54,000" },
  { date: "10/16", item: "トップの写真3点の差し替え", owner: "佐倉", status: "確認待ち", amount: "¥22,000" },
  { date: "10/12", item: "お知らせ投稿の定型文の作成", owner: "中原", status: "完了", amount: "¥16,000" },
  { date: "10/07", item: "問い合わせフォームの項目整理", owner: "高見", status: "完了", amount: "¥45,000" },
];

/* ---------- 骨格の部品 ---------- */

/** 骨格の面。blank は寸法だけ確保した空白、shimmer は光の帯つきの灰色、outline は本物の上に重ねる輪郭 */
type BoneMode = "blank" | "shimmer" | "outline";

function Bone({ mode, className = "", style }: { mode: BoneMode; className?: string; style?: CSSProperties }) {
  const look =
    mode === "shimmer" ? "blk104-bone bg-gray-200" : mode === "outline" ? "shadow-[inset_0_0_0_1px_rgba(217,119,6,0.75)]" : "";
  return <span aria-hidden="true" className={`relative block overflow-hidden ${look} ${className}`} style={style} />;
}

/** 文字1行の骨格。箱の高さ=本物の line-height、中のバーは文字の高さ程度 */
function LineBone({ mode, lh, bar, width }: { mode: BoneMode; lh: number; bar: number; width: string }) {
  const pad = (lh - bar) / 2;
  return (
    <span aria-hidden="true" className="block" style={{ height: lh, paddingTop: pad, paddingBottom: pad }}>
      <Bone mode={mode} className="h-full" style={{ width }} />
    </span>
  );
}

/* ---------- 01 記事一覧 ---------- */

const ARTICLE_ROW = "grid grid-cols-[6rem_minmax(0,1fr)] gap-x-4 border-t border-gray-200 py-4 first:border-t-0 first:pt-0 @md:grid-cols-[9.5rem_minmax(0,1fr)]";

function ArticlesReal({ reserve, imageReady }: { reserve: boolean; imageReady: boolean }) {
  return (
    <ul className="list-none pl-0">
      {ARTICLES.map((a) => (
        <li key={a.title} className={ARTICLE_ROW}>
          <div
            role="img"
            aria-label={`${a.tag}の記事のサムネイル`}
            className={`w-full border border-gray-200 ${reserve || imageReady ? "aspect-[4/3]" : "h-0 border-0"}`}
            style={{ backgroundImage: a.tone }}
          />
          <div className="min-w-0">
            <div className="flex h-[20px] items-center gap-3">
              <span className="inline-flex h-[20px] items-center border border-gray-300 px-2 text-[11px] leading-none text-gray-600">{a.tag}</span>
              <span className="font-display text-[11px] leading-none text-gray-400 tabular-nums">{a.date}</span>
            </div>
            <p className={`mt-2 text-[15px] leading-[24px] font-bold text-gray-900 ${reserve ? "line-clamp-2 min-h-[48px]" : ""}`}>{a.title}</p>
            <p className={`mt-1 text-[13px] leading-[22px] text-gray-500 ${reserve ? "truncate" : ""}`}>{a.summary}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

function ArticlesBones({ mode }: { mode: BoneMode }) {
  return (
    <ul aria-hidden="true" className="list-none pl-0">
      {ARTICLES.map((a, i) => (
        <li key={a.title} className={ARTICLE_ROW}>
          <Bone mode={mode} className="aspect-[4/3] w-full" />
          <div className="min-w-0">
            <div className="flex h-[20px] items-center gap-3">
              <Bone mode={mode} className="h-[20px] w-10" />
              <LineBone mode={mode} lh={20} bar={8} width="4.5rem" />
            </div>
            <div className="mt-2">
              <LineBone mode={mode} lh={24} bar={12} width="100%" />
              <LineBone mode={mode} lh={24} bar={12} width={`${64 - i * 9}%`} />
            </div>
            <div className="mt-1">
              <LineBone mode={mode} lh={22} bar={9} width={`${82 - i * 6}%`} />
            </div>
          </div>
        </li>
      ))}
    </ul>
  );
}

/* ---------- 02 プロフィール ---------- */

function ProfileStats({ children }: { children: (i: number) => ReactNode }) {
  return (
    <dl className="mt-4 grid grid-cols-3 border-t border-gray-200">
      {PROFILE.stats.map((s, i) => (
        <div key={s.label} className="border-l border-gray-200 py-3 pl-3 first:border-l-0 first:pl-0">
          <dt className="text-[11px] leading-[16px] text-gray-500">{s.label}</dt>
          <dd className="mt-1 h-[28px]">{children(i)}</dd>
        </div>
      ))}
    </dl>
  );
}

function ProfileReal({ reserve, imageReady }: { reserve: boolean; imageReady: boolean }) {
  return (
    <div>
      <div className="flex items-start gap-4">
        <div
          role="img"
          aria-label={`${PROFILE.name}の写真の代わりの頭文字`}
          className={`flex w-20 shrink-0 items-center justify-center bg-amber-50 text-[28px] font-bold text-amber-700 ${reserve || imageReady ? "aspect-square" : "h-0 overflow-hidden"}`}
        >
          {PROFILE.initial}
        </div>
        <div className="min-w-0 flex-1">
          <p className={`text-[18px] leading-[28px] font-bold text-gray-900 ${reserve ? "truncate" : ""}`}>{PROFILE.name}</p>
          <p className={`text-[13px] leading-[22px] text-gray-500 ${reserve ? "truncate" : ""}`}>{PROFILE.role}</p>
        </div>
      </div>
      <p className={`mt-4 text-[13.5px] leading-[26px] text-gray-700 ${reserve ? "line-clamp-3 min-h-[78px]" : ""}`}>{PROFILE.bio}</p>
      <ProfileStats>
        {(i) => (
          <span className="font-display text-[20px] leading-[28px] font-semibold text-gray-900 tabular-nums">
            {PROFILE.stats[i].value}
            <span className="ml-0.5 font-sans text-[12px] font-medium text-gray-500">{PROFILE.stats[i].unit}</span>
          </span>
        )}
      </ProfileStats>
    </div>
  );
}

function ProfileBones({ mode }: { mode: BoneMode }) {
  return (
    <div>
      <div className="flex items-start gap-4">
        <Bone mode={mode} className="aspect-square w-20 shrink-0" />
        <div className="min-w-0 flex-1">
          <LineBone mode={mode} lh={28} bar={14} width="7.5rem" />
          <LineBone mode={mode} lh={22} bar={9} width="78%" />
        </div>
      </div>
      <div className="mt-4">
        <LineBone mode={mode} lh={26} bar={10} width="100%" />
        <LineBone mode={mode} lh={26} bar={10} width="96%" />
        <LineBone mode={mode} lh={26} bar={10} width="58%" />
      </div>
      {/* 項目名は先に分かるので本物を出し、値だけを骨格にする */}
      <ProfileStats>{() => <LineBone mode={mode} lh={28} bar={16} width="2.75rem" />}</ProfileStats>
    </div>
  );
}

/* ---------- 03 表 ---------- */

const STATUS_MARK: Record<string, string> = {
  見積中: "bg-amber-600",
  作業中: "bg-gray-900",
  確認待ち: "border border-gray-900",
  完了: "bg-gray-300",
};

function TableFrame({ head, children }: { head: boolean; children: ReactNode }) {
  return (
    <div className="overflow-x-auto">
      <table className="w-full min-w-[34rem] table-fixed border-collapse text-left">
        <colgroup>
          <col className="w-[4.5rem]" />
          <col />
          <col className="w-[5rem]" />
          <col className="w-[6.5rem]" />
          <col className="w-[6.5rem]" />
        </colgroup>
        {head && (
          <thead>
            <tr className="h-[36px] border-b border-gray-900 text-[11px] text-gray-500">
              <th scope="col" className="font-semibold">受付日</th>
              <th scope="col" className="font-semibold">依頼の内容</th>
              <th scope="col" className="font-semibold">担当</th>
              <th scope="col" className="font-semibold">状態</th>
              <th scope="col" className="text-right font-semibold">見積額(税抜)</th>
            </tr>
          </thead>
        )}
        {children}
      </table>
    </div>
  );
}

function TableReal() {
  return (
    <TableFrame head>
      <tbody>
        {ROWS.map((r) => (
          <tr key={r.item} className="h-[44px] border-b border-gray-200 text-[13px] text-gray-800">
            <td className="font-display text-[12px] text-gray-500 tabular-nums">{r.date}</td>
            <td className="truncate pr-4 font-medium text-gray-900">{r.item}</td>
            <td className="truncate">{r.owner}</td>
            <td>
              <span className="inline-flex items-center gap-1.5 text-[12px]">
                <span aria-hidden="true" className={`size-2 ${STATUS_MARK[r.status]}`} />
                {r.status}
              </span>
            </td>
            <td className="text-right font-display text-[13px] font-semibold tabular-nums">{r.amount}</td>
          </tr>
        ))}
      </tbody>
    </TableFrame>
  );
}

function TableBones({ mode, head }: { mode: BoneMode; head: boolean }) {
  return (
    <TableFrame head={head}>
      {/* 見出し行は先に本物を出し、本文の行だけを骨格にする */}
      <tbody aria-hidden="true">
        {ROWS.map((r, i) => (
          <tr key={r.item} className={`h-[44px] border-b ${mode === "outline" ? "border-transparent" : "border-gray-200"}`}>
            <td><Bone mode={mode} className="h-[10px] w-9" /></td>
            <td className="pr-4"><Bone mode={mode} className="h-[11px]" style={{ width: `${88 - ((i * 17) % 40)}%` }} /></td>
            <td><Bone mode={mode} className="h-[11px] w-8" /></td>
            <td><Bone mode={mode} className="h-[11px] w-14" /></td>
            <td><Bone mode={mode} className="ml-auto h-[11px] w-16" /></td>
          </tr>
        ))}
      </tbody>
    </TableFrame>
  );
}

/* ---------- 枠(計測つき) ---------- */

const PHASE_LABEL: Record<Phase, string> = { waiting: "待機中(骨格なし)", skeleton: "読み込み中", loaded: "表示済み" };

function Panel({
  panel,
  phase,
  ms,
  reserve,
  overlay,
  imageReady,
}: {
  panel: (typeof PANELS)[number];
  phase: Phase;
  ms: number;
  reserve: boolean;
  overlay: boolean;
  imageReady: boolean;
}) {
  const titleId = useId();
  const boxRef = useRef<HTMLDivElement>(null);
  const phaseRef = useRef(phase);
  const prevRef = useRef<number | null>(null);
  const widthRef = useRef<number | null>(null);
  const [m, setM] = useState({ before: 0, now: 0, shift: 0 });

  // ResizeObserver の通知より先に、いまの段を控えておく
  useLayoutEffect(() => {
    phaseRef.current = phase;
  }, [phase]);

  useEffect(() => {
    const el = boxRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const box = entry.borderBoxSize?.[0];
      const h = Math.round(box?.blockSize ?? entry.contentRect.height);
      const w = Math.round(box?.inlineSize ?? entry.contentRect.width);
      const prev = prevRef.current;
      const widthChanged = widthRef.current !== null && widthRef.current !== w;
      prevRef.current = h;
      widthRef.current = w;
      // 幅が変わった(画面の回転・スクロールバーの出入り)ときの高さの変化は差し替えのずれに数えず、基準を取り直す
      if (phaseRef.current !== "loaded" || widthChanged) {
        setM({ before: h, now: h, shift: 0 });
      } else {
        setM((cur) => ({ before: cur.before, now: h, shift: cur.shift + (prev === null ? 0 : Math.abs(h - prev)) }));
      }
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const loading = phase !== "loaded";
  const mode: BoneMode = phase === "skeleton" ? "shimmer" : "blank";

  let body: ReactNode;
  if (loading && !reserve) {
    body = <p className="flex h-9 items-center text-[13px] text-gray-400">{phase === "skeleton" ? "読み込み中…" : ""}</p>;
  } else if (loading) {
    body =
      panel.key === "articles" ? <ArticlesBones mode={mode} /> : panel.key === "profile" ? <ProfileBones mode={mode} /> : <TableBones mode={mode} head />;
  } else {
    body = (
      <div className="blk104-swap">
        {panel.key === "articles" ? (
          <ArticlesReal reserve={reserve} imageReady={imageReady} />
        ) : panel.key === "profile" ? (
          <ProfileReal reserve={reserve} imageReady={imageReady} />
        ) : (
          <TableReal />
        )}
      </div>
    );
  }

  const showOverlay = overlay && !loading && reserve;

  return (
    <article aria-labelledby={titleId} className="min-w-0">
      <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-gray-900 pb-2">
        <span className="font-display text-[12px] font-semibold text-gray-400 tabular-nums">{panel.no}</span>
        <h3 id={titleId} className="text-[15px] leading-[1.6] font-bold text-gray-900">
          {panel.label}
        </h3>
        <span className="ml-auto inline-flex items-center gap-1.5 text-[12px] text-gray-600">
          <span
            aria-hidden="true"
            className={`size-2 ${phase === "skeleton" ? "bg-amber-600" : phase === "loaded" ? "bg-gray-900" : "border border-gray-400"}`}
          />
          {PHASE_LABEL[phase]}
          <span className="font-display text-gray-400 tabular-nums">/{ms}ms</span>
        </span>
      </header>

      <div ref={boxRef} aria-busy={loading} className="relative pt-4 pb-4">
        {body}
        {showOverlay && (
          <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-4 [&_dt]:invisible [&_th]:invisible [&_thead_tr]:border-transparent">
            {panel.key === "articles" ? <ArticlesBones mode="outline" /> : panel.key === "profile" ? <ProfileBones mode="outline" /> : <TableBones mode="outline" head />}
          </div>
        )}
      </div>

      {/* 下端の目印。差し替えで高さが変わると、この線から下がずれる */}
      <p className="flex flex-wrap items-baseline gap-x-3 border-t border-dashed border-gray-300 pt-2 text-[12px] text-gray-500">
        <span>枠の高さ</span>
        <span className="font-display tabular-nums">
          {m.before}px → {m.now}px
        </span>
        <span className={`ml-auto font-bold ${m.shift === 0 ? "text-gray-900" : "text-amber-700"}`}>
          ずれ <span className="font-display tabular-nums">{m.shift}px</span>
        </span>
      </p>
    </article>
  );
}

/* ---------- 設定の部品 ---------- */

function Segmented<T extends number>({
  legend,
  name,
  options,
  value,
  onChange,
}: {
  legend: string;
  name: string;
  options: readonly { value: T; label: string }[];
  value: T;
  onChange: (v: T) => void;
}) {
  return (
    <fieldset className="min-w-0">
      <legend className="text-[12px] font-bold text-gray-900">{legend}</legend>
      <div className="mt-2 flex flex-wrap">
        {options.map((o, i) => (
          <label key={o.value} className={`relative ${i > 0 ? "-ml-px" : ""}`}>
            <input
              type="radio"
              name={name}
              value={o.value}
              checked={value === o.value}
              onChange={() => onChange(o.value)}
              className="peer sr-only"
            />
            <span className="relative inline-flex cursor-pointer items-center border border-gray-300 bg-white px-3 py-2 text-[12.5px] font-medium text-gray-700 transition-colors duration-150 hover:text-gray-900 peer-checked:z-10 peer-checked:border-gray-900 peer-checked:bg-gray-900 peer-checked:text-white peer-focus-visible:z-20 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-amber-600 motion-reduce:transition-none">
              {o.label}
            </span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
  return (
    <label className="inline-flex cursor-pointer items-center gap-2 text-[13px] text-gray-800">
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
        className="size-4 accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
      />
      {label}
    </label>
  );
}

/* ---------- 本体 ---------- */

export default function SkeletonLoadingStates() {
  const headingId = useId();
  const [speed, setSpeed] = useState<number>(1800);
  const [threshold, setThreshold] = useState<number>(300);
  const [reserve, setReserve] = useState(true);
  const [overlay, setOverlay] = useState(false);
  const [runId, setRunId] = useState(0);
  const [phases, setPhases] = useState<Phases>(ALL_WAITING);
  const [images, setImages] = useState(NO_IMAGES);
  const [notice, setNotice] = useState("");

  const restart = (next: { speed?: number; threshold?: number; reserve?: boolean }) => {
    if (next.speed !== undefined) setSpeed(next.speed);
    if (next.threshold !== undefined) setThreshold(next.threshold);
    if (next.reserve !== undefined) setReserve(next.reserve);
    setPhases(ALL_WAITING);
    setImages(NO_IMAGES);
    setNotice("");
    setRunId((r) => r + 1);
  };

  useEffect(() => {
    const timers: number[] = [];
    let announced = false;
    let last = 0;
    for (const p of PANELS) {
      const ms = loadMs(speed, p.factor);
      last = Math.max(last, ms);
      if (ms > threshold) {
        timers.push(
          window.setTimeout(() => {
            setPhases((prev) => (prev[p.key] === "loaded" ? prev : { ...prev, [p.key]: "skeleton" }));
            if (!announced) {
              announced = true;
              setNotice("記事一覧・プロフィール・依頼の一覧表を読み込んでいます。");
            }
          }, threshold),
        );
      }
      timers.push(window.setTimeout(() => setPhases((prev) => ({ ...prev, [p.key]: "loaded" })), ms));
      if (!reserve) {
        timers.push(window.setTimeout(() => setImages((prev) => ({ ...prev, [p.key]: true })), ms + IMAGE_DELAY));
      }
    }
    timers.push(
      window.setTimeout(() => {
        if (announced) setNotice("読み込みが終わりました。");
      }, last + 20),
    );
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [runId, speed, threshold, reserve]);

  const lastMs = Math.max(...PANELS.map((p) => loadMs(speed, p.factor)));
  const pct = (ms: number) => `${Math.min(100, (ms / AXIS_MS) * 100)}%`;

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      <div className="flex items-baseline justify-between gap-4 border-b border-gray-900 pb-3">
        <h2 id={headingId} className="text-[clamp(1.45rem,3.2vw,2rem)] leading-[1.35] font-bold tracking-tight">
          読み込み中も配置が<span className="whitespace-nowrap">動かない</span>
          <span className="whitespace-nowrap">スケルトン表示</span>
        </h2>
        <span className="shrink-0 font-display text-[11px] font-semibold text-gray-400 tabular-nums">BLK-104</span>
      </div>
      <p className="mt-5 max-w-3xl text-[14px] leading-[1.9] text-gray-600">
        データを待つあいだ、本物と同じ寸法の灰色の面を置いておくと、読み込み後に差し替えても下の要素が押し下げられません。下の3枠は同時に読み込みを始め、終わった枠から順に本物へ差し替わります。各枠の下端に差し替え前後の高さと、その差(ずれ)をこの画面で実測して表示しています。
      </p>

      {/* 設定 */}
      <div className="mt-8 grid gap-x-10 gap-y-6 border-y border-gray-200 py-6 @3xl:grid-cols-[auto_auto_minmax(0,1fr)]">
        <Segmented
          legend="読み込みにかかる時間"
          name={`${headingId}-speed`}
          options={SPEEDS.map((s) => ({ value: s.ms as number, label: `${s.label} ${s.ms}ms` }))}
          value={speed}
          onChange={(v) => restart({ speed: v })}
        />
        <Segmented
          legend="これより速ければ骨格を出さない"
          name={`${headingId}-threshold`}
          options={THRESHOLDS.map((t) => ({ value: t as number, label: t === 0 ? "0ms(常に出す)" : `${t}ms` }))}
          value={threshold}
          onChange={(v) => restart({ threshold: v })}
        />
        <div className="flex flex-col gap-3 @3xl:items-end">
          <button
            type="button"
            onClick={() => restart({})}
            className="inline-flex items-center justify-center self-start bg-amber-600 px-5 @3xl:self-end py-2.5 text-[13px] font-bold text-white transition-colors duration-200 hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
          >
            もう一度読み込む
          </button>
          <div className="flex flex-col gap-2 @3xl:items-end">
            <Check label="寸法を確保しない(比較用)" checked={!reserve} onChange={(v) => restart({ reserve: !v })} />
            <Check label="読み込み後に骨格の輪郭を重ねる" checked={overlay} onChange={setOverlay} />
          </div>
        </div>
      </div>

      {/* 時間の目盛り */}
      <div className="mt-6">
        <p className="text-[12px] font-bold text-gray-900">各枠が届く時刻と閾値</p>
        <div aria-hidden="true" className="relative mt-3 h-12">
          <div className="absolute inset-x-0 top-6 h-px bg-gray-200" />
          <div
            key={runId}
            className="blk104-progress absolute top-[23.5px] left-0 h-[2px] w-full origin-left bg-gray-900"
            style={{ "--blk104-to": Math.min(1, lastMs / AXIS_MS), animationDuration: `${lastMs}ms` } as CSSProperties}
          />
          <div className="absolute top-1 bottom-0 w-px bg-amber-600" style={{ left: pct(threshold) }}>
            <span className="absolute top-[-2px] left-1.5 text-[11px] font-bold whitespace-nowrap text-amber-700">閾値 {threshold}ms</span>
          </div>
          {PANELS.map((p) => (
            <span
              key={p.key}
              className="absolute top-[20px] -ml-1 flex flex-col items-center"
              style={{ left: pct(loadMs(speed, p.factor)) }}
            >
              <span className={`size-2 ${loadMs(speed, p.factor) > threshold ? "bg-amber-600" : "border border-gray-900 bg-white"}`} />
              <span className="mt-1 font-display text-[10px] font-semibold text-gray-500 tabular-nums">{p.no}</span>
            </span>
          ))}
        </div>
        <div className="flex justify-between font-display text-[10px] text-gray-400 tabular-nums">
          <span>0ms</span>
          <span>{AXIS_MS}ms</span>
        </div>
        <ul className="mt-3 flex list-none flex-wrap gap-x-6 gap-y-1 pl-0 text-[12.5px] leading-[1.8] text-gray-600">
          <li className="flex items-center gap-1.5">
            <span aria-hidden="true" className="size-2 shrink-0 bg-amber-600" />
            閾値より遅い枠だけ骨格を出す
          </li>
          <li className="flex items-center gap-1.5">
            <span aria-hidden="true" className="size-2 shrink-0 border border-gray-900" />
            閾値より速い枠は寸法だけ確保して、そのまま本物を出す
          </li>
        </ul>
      </div>

      {/* 読み込み中の通知(aria-busy の領域の外に1つだけ置く) */}
      <p role="status" aria-live="polite" className="sr-only">
        {notice}
      </p>

      {/* 3枠 */}
      <div className="mt-10 grid gap-x-10 gap-y-12 @4xl:grid-cols-12">
        {PANELS.map((p) => (
          <div key={p.key} className={"min-w-0 " + (p.key === "articles" ? "@4xl:col-span-7" : p.key === "profile" ? "@4xl:col-span-5" : "@4xl:col-span-12")}>
            <Panel
              key={runId}
              panel={p}
              phase={phases[p.key]}
              ms={loadMs(speed, p.factor)}
              reserve={reserve}
              overlay={overlay}
              imageReady={images[p.key]}
            />
          </div>
        ))}
      </div>

      <p className="mt-10 border-t border-gray-200 pt-4 text-[12px] leading-[1.8] text-gray-500">
        ずれの値は、この画面と上の条件で測った枠の高さの差です。フォントの読み込みや広告の差し込みなど、ほかの要因によるずれは含みません。
      </p>

      <style>{`
        .blk104-bone::after {
          content: "";
          position: absolute;
          inset: 0;
          transform: translateX(-100%);
          background: linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent);
          animation: blk104-shimmer 1.4s ease-in-out infinite;
        }
        @keyframes blk104-shimmer { to { transform: translateX(100%); } }
        .blk104-swap { animation: blk104-swap 200ms ease-out backwards; }
        @keyframes blk104-swap { from { opacity: 0; } to { opacity: 1; } }
        .blk104-progress { transform: scaleX(var(--blk104-to)); animation: blk104-progress linear backwards; }
        @keyframes blk104-progress { from { transform: scaleX(0); } }
        @media (prefers-reduced-motion: reduce) {
          .blk104-bone::after { animation: none !important; display: none; }
          .blk104-swap, .blk104-progress { animation: none !important; }
        }
      `}</style>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/skeleton-loading-states.json