First CH Designs
PGE-11Pages

Article Body Reading Layout (Q-Headings, Margin Notes, References)

A complete article body layout for blogs and columns. Headings are written as questions, and the sticky index pairs each question with a one-line answer, so the argument is readable before the body and every section stands alone as a quotable unit. Footnotes, figure notes and sources sit in the right margin aligned to the paragraph that cites them, and collapse under that paragraph on narrow screens from the same DOM. Body marker to note, note to source, and source back to the citing passage are all linked. Includes a pull quote, figure caption and reference list, with no decorative boxes: a 38-character measure, 2.0 line height and hairline rules do the work. All copy is fictional sample text.

Added:
2026-09-24
Dependencies:
None
tags
#page #article #blog #long-form #reading #toc #scrollspy #footnote #margin-note #blockquote #figure-caption #references #hairline #a11y #no-image #no-deps

Preview

制作の考えかた・読み物

最後まで読まれる記事は、本文の組み方が違う

見出し・引用・図版・註・参考文献までを1つの版面に収めた本文レイアウトの解説です。装飾を足さず、行長と行間、そして余白の使い方だけで読ませる構成にしています。

公開
2026.09.24
読了
7
出典
3

記事が読まれないとき、直すべきは文章より先に版面であることが少なくありません。この記事では、当社が実際の制作で使っている本文レイアウトを、見出しの立て方から参考文献の並べ方まで順に説明します。

Q1読了 2

読者はどこで読むのをやめるのか?

離脱は冒頭ではなく、3つ目の見出しの手前に集まる。

記事が読まれない原因を冒頭の書き出しに求める人は多いのですが、当社が制作した記事ページの計測では、離脱がもっとも増えるのは書き出しではありません。読み始めた人の大半は最初の2つの節を通過し、3つ目の見出しの手前で止まります。つまり問題は「入口の掴み」ではなく「途中で現在地を見失うこと」です。

100
導入
86
第1節
71
第2節
38
第3節
31
第4節
27
末尾
図1 記事のどこまで読まれたか(節の区切りごとの到達率・サンプル値)。第2節から第3節にかけて到達率が33ポイント落ちている。

この形は、版面の作りで動かせます。行長を和文38文字前後に抑え、行間を2.0まで開け、見出しを読み手の疑問の言い回しに合わせる。装飾のボックスを足すのではなく、余白と罫線だけで節の境目を示すほうが、最後まで読まれる記事になりました。

Q2読了 2

見出しを質問形にすると何が変わるのか?

節がそのまま引用の単位になり、抜き出されても意味が壊れない。

「サービスの特長」のような名詞止めの見出しは、その節だけを取り出すと何の話か分かりません。対して「初期費用はいくらかかるのか?」は、見出しと直後の2〜3文だけで質問と答えの対になります。検索結果やAIの回答に引用されるとき、抜き出される範囲はたいてい見出し1つぶんです。引用されて困らない単位で書いておくと、文脈の欠けた引用が減ります。

読み手が知りたいことを、そのままの言い回しで見出しに置く。答えは見出しの直後に、1文で先に書く。
記事づくりの社内ガイドラインより

この記事の目次も同じ考えで作っています。質問の一覧と、それぞれの答えの1行を対にして並べているので、本文を読む前に主張の全体が分かります。目次が見出しの文字列を写しただけなら、そこに情報は増えません。

Q3読了 1

図表は本文のどこに置くべきか?

説明した段落の直後。図だけで意味が通る説明文を必ず添える。

図や表は、読み手が「いま知りたい」と思った位置に置きます。判断の目安は3つです。

  1. その図を説明した段落の直後に置く。節の終わりにまとめて並べない。
  2. 本文を読まずに図だけを見た人にも意味が通る説明文を添える。図版番号は本文からの参照に使う。
  3. 数値の出どころは図の説明文の中に書く。本文へ持ち込むと読みの流れが切れる。

注記や補足は本文の流れから外し、広い画面では本文の右の余白に、狭い画面では段落の直下に置いています。本文中の小さな番号を押すと、対応する註に目印が付きます。

Q4読了 2

参考文献は誰のために載せるのか?

後から根拠をたどる人のため。本文と出典を往復できる形にする。

参考文献を記事の末尾に並べただけでは、ほとんど読まれません。意味が出るのは、本文のどの主張がどの資料に基づくのかを往復できるときです。この記事では、註から出典へ、出典から引用箇所へ、どちらの向きにも移動できるようにしています。

出典を明示する作業は、書き手にとっての検算でもあります。数字の根拠が思い出せない主張は、たいてい書きすぎています。根拠の欄が埋まらない段落は、断定をやめて観察の記述に書き直すのが早道です。

参考文献

  1. 出典 1

    記事ページの読了率に関する社内計測メモ

    制作部門・内部資料(サンプル) 2026

    引用箇所

  2. 出典 2

    見出しと本文の対応づけに関する編集ガイドライン

    編集ガイドライン 第3版(サンプル) 2026

    引用箇所

  3. 出典 3

    記事の出典表記と更新手順についての制作メモ

    制作部門・内部資料(サンプル) 2025

    引用箇所

掲載している数値・資料名はすべてレイアウト説明のためのサンプルです。実際の記事では、出典の名称と参照年を明記してください。

"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["500", "600"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 記事本文の読み物レイアウト(質問形見出し・余白註・参考文献の往復)
 * 既存の長文ページ型は「条番号への一点ジャンプ」で全経路が繋がる書類の版面で、註も図版も出典も
 * 持たない。本ブロックは本文+余白註の2列版面に振り切り、脚注・図表の注記・出典が参照した行の真横に
 * 並ぶ。目次は見出しの写しではなく質問と答えの1行を対で持ち(節がそのまま引用の単位になる)、
 * 参考文献は本文の註へ、註は本文の位置へ戻れる往復の線を張る。
 * 飽和idiomは英字キッカーと末尾の「→」リンクを外し、角丸・出現アニメも使わない。掲載文は架空のサンプル。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **余白註は「段落ごとの2列グリッド」で置く**: 記事全体を2列にすると註が本文のどの行に付くのか
 *   分からなくなる。段落1つ=grid-cols-[minmax(0,1fr)_9.5rem] の1行にして註を2列目に入れると、
 *   註の上端が参照元の段落の上端に自動で揃う。狭幅では1列に落ちるだけで、同じDOMのまま
 *   註が段落の直下にぶら下がる(モバイル用に註を二重に書かなくてよい)。
 * - **和文の行長は em で決める**: `max-w-[38em]` がそのまま38文字前後になる。px だと文字サイズを
 *   変えた瞬間に行長が崩れる。
 * - **目次に「答えの1行」を持たせる**: 見出しを質問形にすると、目次が Q→A の索引になり、
 *   読む前に全体の主張が読める。見出しの文字列を写すだけの目次より情報量が増える。
 * - **スクロールスパイは rect の総なめ**: 可変高の節が連続する記事では「上端が閾値を超えた最後の節」
 *   が正解。rAF で束ねた1パスで読了率も同時に出す。
 * - **往復リンクの目印は重ねた面の opacity**: 背景色そのものを transition すると reduced-motion で
 *   止めたとき色が戻らないことがある。-z-10 の面を敷いて opacity だけ動かす。
 */

type Seg = string | { mark: number };

type Block =
  | { kind: "p"; body: Seg[]; note?: number }
  | { kind: "quote"; body: string; by: string }
  | { kind: "list"; lead: string; items: string[]; note?: number }
  | { kind: "figure"; note?: number };

type Section = {
  id: string;
  /** 見出しは質問形にする(節が引用の単位になる) */
  question: string;
  /** 目次に出す「答えの1行」 */
  answer: string;
  minutes: number;
  blocks: Block[];
};

const sections: Section[] = [
  {
    id: "drop",
    question: "読者はどこで読むのをやめるのか?",
    answer: "離脱は冒頭ではなく、3つ目の見出しの手前に集まる。",
    minutes: 2,
    blocks: [
      {
        kind: "p",
        body: [
          "記事が読まれない原因を冒頭の書き出しに求める人は多いのですが、当社が制作した記事ページの計測では、離脱がもっとも増えるのは書き出しではありません。読み始めた人の大半は最初の2つの節を通過し、3つ目の見出しの手前で止まります",
          { mark: 1 },
          "。つまり問題は「入口の掴み」ではなく「途中で現在地を見失うこと」です。",
        ],
        note: 1,
      },
      { kind: "figure", note: 2 },
      {
        kind: "p",
        body: [
          "この形は、版面の作りで動かせます。行長を和文38文字前後に抑え、行間を2.0まで開け、見出しを読み手の疑問の言い回しに合わせる。装飾のボックスを足すのではなく、余白と罫線だけで節の境目を示すほうが、最後まで読まれる記事になりました。",
        ],
      },
    ],
  },
  {
    id: "heading",
    question: "見出しを質問形にすると何が変わるのか?",
    answer: "節がそのまま引用の単位になり、抜き出されても意味が壊れない。",
    minutes: 2,
    blocks: [
      {
        kind: "p",
        body: [
          "「サービスの特長」のような名詞止めの見出しは、その節だけを取り出すと何の話か分かりません。対して「初期費用はいくらかかるのか?」は、見出しと直後の2〜3文だけで質問と答えの対になります",
          { mark: 3 },
          "。検索結果やAIの回答に引用されるとき、抜き出される範囲はたいてい見出し1つぶんです。引用されて困らない単位で書いておくと、文脈の欠けた引用が減ります。",
        ],
        note: 3,
      },
      {
        kind: "quote",
        body: "読み手が知りたいことを、そのままの言い回しで見出しに置く。答えは見出しの直後に、1文で先に書く。",
        by: "記事づくりの社内ガイドラインより",
      },
      {
        kind: "p",
        body: [
          "この記事の目次も同じ考えで作っています。質問の一覧と、それぞれの答えの1行を対にして並べているので、本文を読む前に主張の全体が分かります",
          { mark: 4 },
          "。目次が見出しの文字列を写しただけなら、そこに情報は増えません。",
        ],
        note: 4,
      },
    ],
  },
  {
    id: "figure",
    question: "図表は本文のどこに置くべきか?",
    answer: "説明した段落の直後。図だけで意味が通る説明文を必ず添える。",
    minutes: 1,
    blocks: [
      {
        kind: "list",
        lead: "図や表は、読み手が「いま知りたい」と思った位置に置きます。判断の目安は3つです。",
        items: [
          "その図を説明した段落の直後に置く。節の終わりにまとめて並べない。",
          "本文を読まずに図だけを見た人にも意味が通る説明文を添える。図版番号は本文からの参照に使う。",
          "数値の出どころは図の説明文の中に書く。本文へ持ち込むと読みの流れが切れる。",
        ],
        note: 5,
      },
      {
        kind: "p",
        body: [
          "注記や補足は本文の流れから外し、広い画面では本文の右の余白に、狭い画面では段落の直下に置いています。本文中の小さな番号を押すと、対応する註に目印が付きます。",
        ],
      },
    ],
  },
  {
    id: "source",
    question: "参考文献は誰のために載せるのか?",
    answer: "後から根拠をたどる人のため。本文と出典を往復できる形にする。",
    minutes: 2,
    blocks: [
      {
        kind: "p",
        body: [
          "参考文献を記事の末尾に並べただけでは、ほとんど読まれません。意味が出るのは、本文のどの主張がどの資料に基づくのかを往復できるときです",
          { mark: 6 },
          "。この記事では、註から出典へ、出典から引用箇所へ、どちらの向きにも移動できるようにしています。",
        ],
        note: 6,
      },
      {
        kind: "p",
        body: [
          "出典を明示する作業は、書き手にとっての検算でもあります",
          { mark: 7 },
          "。数字の根拠が思い出せない主張は、たいてい書きすぎています。根拠の欄が埋まらない段落は、断定をやめて観察の記述に書き直すのが早道です。",
        ],
        note: 7,
      },
    ],
  },
];

type Note = { n: number; text: string; ref?: number };

const notes: Note[] = [
  {
    n: 1,
    text: "自社で制作した記事ページ12本の読了計測(サンプル値)。3つ目の見出しの手前で離脱が最大になった。",
    ref: 1,
  },
  {
    n: 2,
    text: "図1の値はサンプル。節の区切りごとに、そこまで到達した読者の割合を示す。到達率は最後の節まで読んだ人を含まない。",
  },
  {
    n: 3,
    text: "見出しと直後の2〜3文で答えが完結しているかを、公開前のチェック項目に入れている。",
    ref: 2,
  },
  {
    n: 4,
    text: "目次の「答えの1行」は、記事の要約としてそのまま流用できる長さ(40字以内)に揃える。",
  },
  {
    n: 5,
    text: "図版の説明文は、本文を読まずに図だけを見た場合に意味が通るかで判断する。",
  },
  {
    n: 6,
    text: "根拠の所在が分かる記事は、公開後の修正がしやすい。数値の更新が1か所で済むため。",
    ref: 3,
  },
  {
    n: 7,
    text: "出典の欄が埋まらない段落は、書き手の記憶だけで書いている段落でもある。",
  },
];

/** 本文中に番号を持つ註(図版・箇条書きの注記には本文側の番号がない) */
const markedNotes = new Set(
  sections
    .flatMap((section) => section.blocks)
    .flatMap((block) => (block.kind === "p" ? block.body : []))
    .flatMap((seg) => (typeof seg === "string" ? [] : [seg.mark])),
);

type Reference = {
  n: number;
  title: string;
  where: string;
  year: string;
  cites: number[];
};

const references: Reference[] = [
  {
    n: 1,
    title: "記事ページの読了率に関する社内計測メモ",
    where: "制作部門・内部資料(サンプル)",
    year: "2026",
    cites: [1],
  },
  {
    n: 2,
    title: "見出しと本文の対応づけに関する編集ガイドライン",
    where: "編集ガイドライン 第3版(サンプル)",
    year: "2026",
    cites: [3],
  },
  {
    n: 3,
    title: "記事の出典表記と更新手順についての制作メモ",
    where: "制作部門・内部資料(サンプル)",
    year: "2025",
    cites: [6],
  },
];

/** 図1: 節ごとの到達率(サンプル値) */
const figureBars = [
  { label: "導入", value: 100 },
  { label: "第1節", value: 86 },
  { label: "第2節", value: 71 },
  { label: "第3節", value: 38 },
  { label: "第4節", value: 31 },
  { label: "末尾", value: 27 },
];

const totalMinutes = sections.reduce((sum, s) => sum + s.minutes, 0);

const sectionId = (id: string) => `article-sec-${id}`;
const noteId = (n: number) => `article-note-${n}`;
const markId = (n: number) => `article-mark-${n}`;
const refId = (n: number) => `article-ref-${n}`;

export default function ArticleReadingLayout() {
  const [activeSection, setActiveSection] = useState(sections[0]!.id);
  const [progress, setProgress] = useState(0);
  const [activeNote, setActiveNote] = useState<number | null>(null);
  const [flashed, setFlashed] = useState<string | null>(null);
  const articleRef = useRef<HTMLDivElement>(null);
  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  // 「いま読んでいる節」と読了率を rAF で束ねた1パスで出す
  useEffect(() => {
    let rafId = 0;
    let queued = false;

    const measure = () => {
      queued = false;
      const article = articleRef.current;
      if (!article) return;

      const threshold = window.innerHeight * 0.3;
      let current = sections[0]!.id;
      for (const section of sections) {
        const el = document.getElementById(sectionId(section.id));
        if (!el) continue;
        if (el.getBoundingClientRect().top <= threshold) current = section.id;
      }
      setActiveSection(current);

      const rect = article.getBoundingClientRect();
      const read = -rect.top + window.innerHeight * 0.6;
      setProgress(
        Math.min(1, Math.max(0, rect.height > 0 ? read / rect.height : 0)),
      );
    };

    const onScroll = () => {
      if (queued) return;
      queued = true;
      rafId = requestAnimationFrame(measure);
    };

    measure();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(rafId);
    };
  }, []);

  useEffect(() => {
    return () => {
      if (flashTimer.current) clearTimeout(flashTimer.current);
    };
  }, []);

  // 目次・註・出典の3経路は、すべてこの1つのジャンプに集約する
  const jumpTo = useCallback((domId: string) => {
    const el = document.getElementById(domId);
    if (!el) return;
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    el.scrollIntoView({
      behavior: reduced ? "auto" : "smooth",
      block: "center",
    });
    el.focus({ preventScroll: true });
    setFlashed(domId);
    if (flashTimer.current) clearTimeout(flashTimer.current);
    flashTimer.current = setTimeout(() => setFlashed(null), 1600);
  }, []);

  const remaining = Math.max(1, Math.ceil(totalMinutes * (1 - progress)));

  const noteById = useMemo(() => {
    const map = new Map<number, Note>();
    for (const note of notes) map.set(note.n, note);
    return map;
  }, []);

  const renderBody = (body: Seg[]) =>
    body.map((seg, i) =>
      typeof seg === "string" ? (
        <span key={i}>{seg}</span>
      ) : (
        <button
          key={i}
          type="button"
          id={markId(seg.mark)}
          tabIndex={0}
          aria-label={`註${seg.mark}を表示`}
          onClick={() => {
            setActiveNote(seg.mark);
            jumpTo(noteId(seg.mark));
          }}
          className={`${montserrat.className} relative -top-[0.35em] mx-[0.15em] border-b border-amber-600/50 px-[0.15em] align-baseline text-[0.66em] font-semibold tabular-nums text-amber-700 transition-colors duration-150 hover:bg-amber-50 hover:border-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
            flashed === markId(seg.mark) ? "bg-amber-100" : ""
          }`}
        >
          {seg.mark}
        </button>
      ),
    );

  /** 余白註: 広幅では本文の右、狭幅では参照した段落の直下に落ちる(同じDOMのまま) */
  const renderNote = (n?: number) => {
    if (!n) return <div aria-hidden="true" />;
    const note = noteById.get(n);
    if (!note) return <div aria-hidden="true" />;
    const active = activeNote === n;
    return (
      <aside
        id={noteId(n)}
        tabIndex={-1}
        className="relative isolate mt-3 border-l-2 border-gray-200 pl-3 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 @3xl:mt-0 @3xl:border-l @3xl:pt-[0.3em]"
      >
        <span
          aria-hidden="true"
          className={`pointer-events-none absolute -inset-x-2 -inset-y-1.5 -z-10 bg-amber-50 transition-opacity duration-500 ease-out motion-reduce:transition-none ${
            active || flashed === noteId(n) ? "opacity-100" : "opacity-0"
          }`}
        />
        <p className="text-[0.72em] leading-[1.85] text-gray-500">
          <span
            className={`${montserrat.className} mr-1.5 font-semibold tabular-nums ${
              active ? "text-amber-700" : "text-gray-400"
            }`}
          >
            註{note.n}
          </span>
          {note.text}
        </p>
        <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 empty:hidden">
          {markedNotes.has(note.n) && (
            <button
              type="button"
              onClick={() => jumpTo(markId(note.n))}
              className="text-[0.68em] leading-[1.7] font-semibold text-gray-500 underline decoration-gray-300 underline-offset-4 transition-colors duration-150 hover:text-gray-900 hover:decoration-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
            >
              本文の位置
            </button>
          )}
          {note.ref && (
            <button
              type="button"
              onClick={() => jumpTo(refId(note.ref!))}
              className={`${montserrat.className} text-[0.68em] leading-[1.7] font-semibold tabular-nums text-amber-700 underline decoration-amber-600/40 underline-offset-4 transition-colors duration-150 hover:decoration-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none`}
            >
              出典 {note.ref}
            </button>
          )}
        </p>
      </aside>
    );
  };

  /** 段落1つ=2列グリッドの1行。註の上端が参照元の段落の上端に揃う */
  const row = (key: string, main: React.ReactNode, note?: number) => (
    <div
      key={key}
      className="mt-6 grid gap-x-6 first:mt-0 @3xl:grid-cols-[minmax(0,1fr)_8.5rem] @3xl:gap-x-5 @5xl:grid-cols-[minmax(0,1fr)_12rem] @5xl:gap-x-8"
    >
      <div className="min-w-0">{main}</div>
      {renderNote(note)}
    </div>
  );

  return (
    <section className="@container w-full max-w-6xl bg-white px-1 text-gray-900">
      {/* 記事ヘッダー */}
      <header className="border-b border-gray-900 pb-6">
        <p className="text-[11px] font-semibold text-gray-500">
          制作の考えかた・読み物
        </p>
        <h2 className="mt-2 max-w-[24em] text-[clamp(1.55rem,3.4cqw,2.15rem)] leading-[1.45] font-bold tracking-tight">
          最後まで読まれる記事は、本文の組み方が違う
        </h2>
        <div className="mt-4 flex flex-col gap-y-3 @2xl:flex-row @2xl:items-end @2xl:justify-between @2xl:gap-x-10">
          <p className="max-w-[36em] text-[13px] leading-[1.95] font-medium text-gray-600">
            見出し・引用・図版・註・参考文献までを1つの版面に収めた本文レイアウトの解説です。装飾を足さず、行長と行間、そして余白の使い方だけで読ませる構成にしています。
          </p>
          <dl
            className={`${montserrat.className} flex shrink-0 gap-x-6 text-[11px] tabular-nums text-gray-500`}
          >
            <div>
              <dt className="text-gray-400">公開</dt>
              <dd className="mt-0.5 font-semibold text-gray-700">2026.09.24</dd>
            </div>
            <div>
              <dt className="text-gray-400">読了</dt>
              <dd className="mt-0.5 font-semibold text-gray-700">
                {totalMinutes}分
              </dd>
            </div>
            <div>
              <dt className="text-gray-400">出典</dt>
              <dd className="mt-0.5 font-semibold text-gray-700">
                {references.length}件
              </dd>
            </div>
          </dl>
        </div>
      </header>

      <div className="grid gap-y-10 @3xl:grid-cols-[11.5rem_1fr] @3xl:gap-x-6 @5xl:grid-cols-[16rem_1fr] @5xl:gap-x-12">
        {/* 質問の索引: 見出しの写しではなく、質問と答えの1行を対で持つ */}
        <nav
          aria-label="この記事の質問"
          className="pt-8 @3xl:sticky @3xl:top-6 @3xl:self-start @3xl:pt-10"
        >
          <div className="flex items-baseline justify-between gap-x-3">
            <p className="text-[11px] font-semibold text-gray-500">
              この記事の質問
            </p>
            <p
              className={`${montserrat.className} text-[11px] tabular-nums text-gray-400`}
            >
              残り {remaining}分
            </p>
          </div>
          <div className="mt-2 h-px w-full bg-gray-200" aria-hidden="true">
            <div
              className="h-px w-full origin-left bg-amber-600 transition-transform duration-200 ease-out motion-reduce:transition-none"
              style={{ transform: `scaleX(${progress})` }}
            />
          </div>

          <ol className="mt-4 border-t border-gray-200">
            {sections.map((section, i) => {
              const active = activeSection === section.id;
              return (
                <li key={section.id} className="border-b border-gray-200">
                  <a
                    href={`#${sectionId(section.id)}`}
                    onClick={(e) => {
                      e.preventDefault();
                      jumpTo(sectionId(section.id));
                    }}
                    aria-current={active ? "true" : undefined}
                    className="group relative block py-3 pr-1 pl-3 transition-colors duration-200 hover:bg-amber-50/70 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                  >
                    <span
                      aria-hidden="true"
                      className={`absolute inset-y-0 left-0 w-[2px] origin-center bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                        active ? "scale-y-100" : "scale-y-0"
                      }`}
                    />
                    <span className="flex items-baseline gap-x-2">
                      <span
                        className={`${montserrat.className} shrink-0 text-[11px] tabular-nums ${
                          active ? "text-amber-700" : "text-gray-400"
                        }`}
                      >
                        Q{i + 1}
                      </span>
                      <span
                        className={`text-[12.5px] leading-[1.65] ${
                          active
                            ? "font-bold text-gray-900"
                            : "font-medium text-gray-700"
                        }`}
                      >
                        {section.question}
                      </span>
                    </span>
                    <span className="mt-1 flex items-baseline gap-x-2 pl-[1.7rem]">
                      <span
                        className={`text-[11px] leading-[1.75] ${
                          active ? "text-gray-700" : "text-gray-400"
                        }`}
                      >
                        {section.answer}
                      </span>
                    </span>
                  </a>
                </li>
              );
            })}
          </ol>

          <p className="mt-4 text-[11px] leading-[1.8] text-gray-400">
            答えの1行は各節の要約です。読む前に主張の全体が分かるよう、見出しと対にして並べています。
          </p>
        </nav>

        {/* 本文: 行長は 38em ≒ 和文38文字。文字サイズは1箇所で決め、子は em 相対にする */}
        <div
          ref={articleRef}
          style={{ fontSize: "15px" }}
          className="@3xl:pt-10"
        >
          <p className="max-w-[38em] border-l-2 border-amber-600 pl-4 text-[1em] leading-[2] font-medium text-gray-800">
            記事が読まれないとき、直すべきは文章より先に版面であることが少なくありません。この記事では、当社が実際の制作で使っている本文レイアウトを、見出しの立て方から参考文献の並べ方まで順に説明します。
          </p>

          {sections.map((section, si) => (
            <section
              key={section.id}
              id={sectionId(section.id)}
              tabIndex={-1}
              className="relative isolate mt-12 scroll-mt-20 border-t border-gray-200 pt-6 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
            >
              <span
                aria-hidden="true"
                className={`pointer-events-none absolute -inset-x-3 -inset-y-2 -z-10 bg-amber-50 transition-opacity duration-500 ease-out motion-reduce:transition-none ${
                  flashed === sectionId(section.id)
                    ? "opacity-100"
                    : "opacity-0"
                }`}
              />
              <p
                className={`${montserrat.className} text-[0.72em] font-semibold tabular-nums text-amber-700`}
              >
                Q{si + 1}
                <span className="ml-2 font-medium text-gray-400">
                  読了 {section.minutes}分
                </span>
              </p>
              <h3 className="mt-1.5 max-w-[24em] text-[1.22em] leading-[1.6] font-bold tracking-tight">
                {section.question}
              </h3>
              <p className="mt-2 max-w-[38em] text-[0.92em] leading-[1.9] font-bold text-gray-700">
                {section.answer}
              </p>

              <div className="mt-6">
                {section.blocks.map((block, bi) => {
                  const key = `${section.id}-${bi}`;

                  if (block.kind === "p") {
                    return row(
                      key,
                      <p className="max-w-[38em] text-[1em] leading-[2] text-gray-800">
                        {renderBody(block.body)}
                      </p>,
                      block.note,
                    );
                  }

                  if (block.kind === "quote") {
                    return row(
                      key,
                      <figure className="max-w-[34em] border-l-2 border-amber-600 py-1 pl-5">
                        <blockquote className="text-[1.05em] leading-[1.95] font-bold text-gray-900">
                          {block.body}
                        </blockquote>
                        <figcaption className="mt-2 text-[0.76em] leading-[1.8] text-gray-500">
                          {block.by}
                        </figcaption>
                      </figure>,
                    );
                  }

                  if (block.kind === "list") {
                    return row(
                      key,
                      <div className="max-w-[38em]">
                        <p className="text-[1em] leading-[2] text-gray-800">
                          {block.lead}
                        </p>
                        <ol className="mt-3 border-b border-gray-200">
                          {block.items.map((item, ii) => (
                            <li
                              key={ii}
                              className="grid grid-cols-[1.9em_1fr] gap-x-2 border-t border-gray-200 py-2.5"
                            >
                              <span
                                className={`${montserrat.className} pt-[0.4em] text-[0.76em] tabular-nums text-gray-400`}
                                aria-hidden="true"
                              >
                                {String(ii + 1).padStart(2, "0")}
                              </span>
                              <span className="text-[0.94em] leading-[1.95] text-gray-700">
                                {item}
                              </span>
                            </li>
                          ))}
                        </ol>
                      </div>,
                      block.note,
                    );
                  }

                  // 図版: 値は本文からも読めるよう数値を併記する
                  return row(
                    key,
                    <figure className="max-w-[38em] border-t border-gray-900 pt-4">
                      <div className="flex items-end gap-x-2 @xl:gap-x-3">
                        {figureBars.map((bar) => (
                          <div
                            key={bar.label}
                            className="flex min-w-0 flex-1 flex-col items-center"
                          >
                            <span
                              className={`${montserrat.className} text-[0.7em] font-semibold tabular-nums text-gray-500`}
                            >
                              {bar.value}
                            </span>
                            <div
                              className="mt-1 w-full bg-amber-600/85"
                              style={{
                                height: `${Math.round(bar.value * 0.72)}px`,
                              }}
                            />
                            <span className="mt-1.5 truncate text-[0.66em] leading-[1.6] text-gray-500">
                              {bar.label}
                            </span>
                          </div>
                        ))}
                      </div>
                      <figcaption className="mt-3 border-t border-gray-200 pt-2 text-[0.78em] leading-[1.85] text-gray-500">
                        <span className="font-bold text-gray-700">図1</span>
                         記事のどこまで読まれたか(節の区切りごとの到達率・サンプル値)。第2節から第3節にかけて到達率が33ポイント落ちている。
                      </figcaption>
                    </figure>,
                    block.note,
                  );
                })}
              </div>
            </section>
          ))}

          {/* 参考文献: 引用箇所から本文の註へ戻れるようにする */}
          <div className="mt-10 border-t-2 border-gray-900 pt-3">
            <h3 className="text-[0.87em] leading-[1.7] font-bold tracking-tight">
              参考文献
            </h3>
            <ol className="mt-4 border-b border-gray-200">
              {references.map((ref) => (
                <li
                  key={ref.n}
                  id={refId(ref.n)}
                  tabIndex={-1}
                  className="relative isolate grid gap-y-1.5 border-t border-gray-200 py-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 @xl:grid-cols-[4.5em_1fr] @xl:gap-x-4"
                >
                  <span
                    aria-hidden="true"
                    className={`pointer-events-none absolute -inset-x-3 inset-y-0 -z-10 bg-amber-50 transition-opacity duration-500 ease-out motion-reduce:transition-none ${
                      flashed === refId(ref.n) ? "opacity-100" : "opacity-0"
                    }`}
                  />
                  <span
                    className={`${montserrat.className} text-[0.8em] leading-[1.9] font-semibold tabular-nums text-amber-700`}
                  >
                    出典 {ref.n}
                  </span>
                  <div className="max-w-[40em]">
                    <p className="text-[0.9em] leading-[1.9] font-bold text-gray-900">
                      {ref.title}
                    </p>
                    <p className="mt-0.5 text-[0.8em] leading-[1.9] text-gray-500">
                      {ref.where} {ref.year}年
                    </p>
                    <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
                      <span className="text-[0.72em] font-semibold text-gray-400">
                        引用箇所
                      </span>
                      {ref.cites.map((n) => (
                        <button
                          key={n}
                          type="button"
                          onClick={() => {
                            setActiveNote(n);
                            jumpTo(markId(n));
                          }}
                          className={`${montserrat.className} border-b border-amber-600/50 text-[0.76em] font-semibold tabular-nums text-amber-700 transition-colors duration-150 hover:border-amber-700 hover:bg-amber-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none`}
                        >
                          註{n}
                        </button>
                      ))}
                    </p>
                  </div>
                </li>
              ))}
            </ol>
            <p className="mt-5 max-w-[40em] text-[0.8em] leading-[1.95] text-gray-500">
              掲載している数値・資料名はすべてレイアウト説明のためのサンプルです。実際の記事では、出典の名称と参照年を明記してください。
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/article-reading-layout.json