First CH Designs
BLK-57Blocks

実績・事例(ピン留め横送り台帳)

区間を画面にピン留めし、縦スクロールの進捗をそのまま横移動へ写像する実績セクション。送るための横入力(スワイプ・ドラッグ・矢印ボタン)を一切持たず、ページを読み進めるだけで5件の台帳が右から左へ流れる。固定はJSではなく「背の高い助走路+sticky」で作り、JSは進捗pを読んで --pw-x(px)と --pw-p(0〜1)の2変数を書くだけ(出力はtransformのみ・stateはカウンタ表示ぶんだけ更新する)。助走路の高さは横のはみ出し量から毎フレーム測り直すため、画面幅が変わっても縦と横の体感速度が揃う。板は幅も上下位置も1枚ずつ違え、番号・種別・一文・3項目の実数(期間/規模/変化)を載せた台帳スリップとして読ませる。固定ヘッダーの下へ1段目が潜らないよう、天井はヘッダーの実測高(--pw-top)ぶん下げる。ピン留め中はフォーカス送りで祖先がスクロールして位置が破綻するため、focusを捕まえてその板が中央に来る縦位置へページを送り返す。prefers-reduced-motion と、固定する余地の無い低い箱へ埋め込まれたときは、同じマークアップのまま素の横スクロール+scroll-snapへ降格する。画像素材ゼロ・依存ゼロ・角丸なし。

追加日:
2026-09-11
依存:
なし
tags
#block #works #case-study #pinned #sticky #scroll-linked #horizontal #ledger #no-image #no-dependency

プレビュー

実績

手を入れた場所と、
変わった数字だけを並べます

完成画面の見栄えではなく、どこを組み替えて何が動いたか。5件ぶんの台帳をそのまま横へ流します。

01/05

コーポレートサイト

2026
  • 01コーポレートサイト

    図面から現場まで、 一続きで見せる会社案内

    設備・工程・人の3本に情報を束ね直し、問い合わせ前に「何ができる会社か」が読み切れる構造へ。

    期間
    8週
    ページ
    14P
    問合せ
    2.4x
  • 02採用サイト

    働く時間の 実像を出す採用ページ

    職種紹介を先に置かず、1日の流れと数字を先頭へ。応募前の不安を潰す順番に並べ替えました。

    期間
    6週
    取材
    9名
    応募
    3.1x
  • 03オンラインストア

    定番を隠さない 食品ブランドの売り場

    新着で定番が押し出される構造をやめ、棚を固定。回遊よりも「いつもの1本」に早く着く導線へ。

    期間
    10週
    商品
    62点
    CVR
    +38%
  • 04ランディングページ

    相談の前に 費用が分かる1枚

    料金を最下部から2画面目へ移し、比較表と条件の注記を同じ面に。離脱の主因を先に片付けました。

    期間
    3週
    検証
    4案
    離脱
    -27%
  • 05サービスサイト

    機能ではなく 業務の順番で語る

    機能一覧を畳み、現場の手順に沿って画面を出す構成へ。導入検討の担当者が説明に使える面を目指しました。

    期間
    7週
    画面
    21面
    商談
    1.9x
BLK-57そのまま下へスクロールすると横へ進みます横にスクロールすると続きが見られます
"use client";

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

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

/**
 * 実績・事例(ピン留め横送り台帳) / Works — Pinned Horizontal Ledger
 *
 * 既存の実績系(BLK-32 / BLK-40)は「利用者が横方向に入力して送る」カルーセルなので、本ブロックは
 * 入力軸と出力軸を直交させた: 区間を画面にピン留めし、**縦スクロールの進捗をそのまま横移動へ写像**する。
 * 情報密度も揃えず、1件ごとに幅の違う台帳スリップ(番号・種別・一文・3項目の実数)を並べて読ませる。
 * 配色・文言・数値はすべて First CH ブランドの架空プレースホルダー。画像素材ゼロ・依存ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **ピン留めの本体は「助走路 + sticky」の2枚**: 背の高い助走路 <div> の中に `sticky top-0 h-[100svh]`
 *   の舞台を1枚置くだけで固定は成立する。JS は固定を作らない(scroll 監視で position を切り替えると必ず
 *   ガタつく)。JS がやるのは助走路の進捗 p を読んで CSS 変数を書くことだけ。
 * - **縦の走行距離を横の走行距離から決める**: 助走路の高さを vh の決め打ちにすると、画面幅で
 *   「横に進む量」が変わるのに縦だけ固定になり、広い画面では余り・狭い画面では足りなくなる。
 *   `助走路 = 舞台の高さ + 横のはみ出し量 × 係数` で毎回測り直すと、どの幅でも体感速度が揃う。
 * - **出力は transform だけ・state は数えるためだけ**: 位置は `--pw-x`(px)と `--pw-p`(0〜1)の
 *   2変数に集約し、React の state はカウンタ表示のぶんだけ(値が変わったときのみ setState)。
 *   rAF で1フレーム1回に畳むので scroll ハンドラ内では何も計算しない。
 * - **天井は「画面の上端」ではなく「貼り付いたヘッダーの下端」**: `top-0 h-[100svh]` と書くと、固定
 *   ヘッダーのあるサイトでは舞台の1段目(カウンタ・進捗)がヘッダーの下へ潜って永久に読めない。
 *   ヘッダーの実測高を `--pw-top` に書き、`top` と `height` の両方を calc で引く(片方だけだと溢れる)。
 * - **ピン留めとキーボード操作は相反する**: 画面外のパネル内リンクにフォーカスが移ると、ブラウザは
 *   祖先をスクロールして見せようとする(transform で動かしている舞台では位置が破綻する)。
 *   focus を捕まえて「その板が中央に来る縦位置」へ window を送り返すと、Tab の巡回でも成立する。
 * - **reduced-motion では固定そのものをやめる**: 動きを止めるだけだと中身が1枚目で止まって読めなくなる。
 *   助走路と舞台の高さを auto へ戻し、トラックを素の overflow-x-auto + scroll-snap に降格させると、
 *   同じマークアップのまま「普通の横スクロール」になる(CSS だけで分岐でき、JS は早期 return できる)。
 */

type Work = {
  no: string;
  kind: string;
  year: string;
  title: string;
  lead: string;
  facts: { label: string; value: string }[];
  /** 板ごとに幅を変えてリズムを作る(ビューポートではなく入れ物基準の % にして、狭い枠へ埋め込んでも比率が変わらないようにする) */
  width: string;
  /** 上下位置も1枚ずつずらす(等間隔の列に見せない) */
  offset: string;
};

const works: Work[] = [
  {
    no: "01",
    kind: "コーポレートサイト",
    year: "2026",
    title: "図面から現場まで、\n一続きで見せる会社案内",
    lead: "設備・工程・人の3本に情報を束ね直し、問い合わせ前に「何ができる会社か」が読み切れる構造へ。",
    facts: [
      { label: "期間", value: "8週" },
      { label: "ページ", value: "14P" },
      { label: "問合せ", value: "2.4x" },
    ],
    width: "w-[80%] sm:w-[52%] lg:w-[38%]",
    offset: "self-start mt-[4vh]",
  },
  {
    no: "02",
    kind: "採用サイト",
    year: "2025",
    title: "働く時間の\n実像を出す採用ページ",
    lead: "職種紹介を先に置かず、1日の流れと数字を先頭へ。応募前の不安を潰す順番に並べ替えました。",
    facts: [
      { label: "期間", value: "6週" },
      { label: "取材", value: "9名" },
      { label: "応募", value: "3.1x" },
    ],
    width: "w-[84%] sm:w-[44%] lg:w-[31%]",
    offset: "self-end mb-[6vh]",
  },
  {
    no: "03",
    kind: "オンラインストア",
    year: "2025",
    title: "定番を隠さない\n食品ブランドの売り場",
    lead: "新着で定番が押し出される構造をやめ、棚を固定。回遊よりも「いつもの1本」に早く着く導線へ。",
    facts: [
      { label: "期間", value: "10週" },
      { label: "商品", value: "62点" },
      { label: "CVR", value: "+38%" },
    ],
    width: "w-[80%] sm:w-[56%] lg:w-[42%]",
    offset: "self-start mt-[10vh]",
  },
  {
    no: "04",
    kind: "ランディングページ",
    year: "2026",
    title: "相談の前に\n費用が分かる1枚",
    lead: "料金を最下部から2画面目へ移し、比較表と条件の注記を同じ面に。離脱の主因を先に片付けました。",
    facts: [
      { label: "期間", value: "3週" },
      { label: "検証", value: "4案" },
      { label: "離脱", value: "-27%" },
    ],
    width: "w-[82%] sm:w-[40%] lg:w-[28%]",
    offset: "self-end mb-[2vh]",
  },
  {
    no: "05",
    kind: "サービスサイト",
    year: "2026",
    title: "機能ではなく\n業務の順番で語る",
    lead: "機能一覧を畳み、現場の手順に沿って画面を出す構成へ。導入検討の担当者が説明に使える面を目指しました。",
    facts: [
      { label: "期間", value: "7週" },
      { label: "画面", value: "21面" },
      { label: "商談", value: "1.9x" },
    ],
    width: "w-[84%] sm:w-[50%] lg:w-[36%]",
    offset: "self-start mt-[7vh]",
  },
];

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);

/** 上に貼り付いたヘッダーの高さ(ピン留めの天井はそのぶん下がる) */
function stickyHeaderHeight(): number {
  let offset = 0;
  const candidates = document.querySelectorAll<HTMLElement>("header, [data-sticky-header]");
  candidates.forEach((el) => {
    const cs = getComputedStyle(el);
    if (cs.position !== "fixed" && cs.position !== "sticky") return;
    if (Math.abs(parseFloat(cs.top || "0")) > 1) return;
    const h = el.getBoundingClientRect().height;
    if (h > 0 && h < window.innerHeight * 0.3) offset = Math.max(offset, h);
  });
  return Math.round(offset);
}

/** 直近の「切り取られる箱」を探す(ページそのものは対象外) */
function findClipper(el: HTMLElement): HTMLElement | null {
  let node = el.parentElement;
  while (node && node !== document.body && node !== document.documentElement) {
    const oy = getComputedStyle(node).overflowY;
    if (oy === "auto" || oy === "scroll" || oy === "hidden") return node;
    node = node.parentElement;
  }
  return null;
}

export default function PinnedHorizontalWorks() {
  const sectionRef = useRef<HTMLElement | null>(null);
  const runwayRef = useRef<HTMLDivElement | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);
  const trackRef = useRef<HTMLUListElement | null>(null);
  const rafRef = useRef<number | null>(null);
  const runwayPx = useRef(0);
  const [index, setIndex] = useState(0);

  useEffect(() => {
    const section = sectionRef.current;
    const runway = runwayRef.current;
    const stage = stageRef.current;
    const track = trackRef.current;
    if (!section || !runway || !stage || !track) return;
    // reduced-motion ではピン留め自体を使わない(CSS 側で素の横スクロールへ降格する)
    const reduced =
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) return;
    // 背の低い箱へ埋め込まれている(=固定する余地が無い)ときも同じ平置きへ降格する
    const clipper = findClipper(section);
    if (clipper && clipper.clientHeight > 0 && clipper.clientHeight < stage.offsetHeight * 0.8) {
      section.classList.add("pw-flat");
      return;
    }

    const applyTop = () => {
      section.style.setProperty("--pw-top", `${stickyHeaderHeight()}px`);
    };

    const measure = () => {
      rafRef.current = null;
      const stageH = stage.offsetHeight;
      // 横のはみ出し量。これが「この区間で横に進む距離」そのもの
      const travel = Math.max(0, track.scrollWidth - stage.clientWidth);
      // 縦の走行距離を横から決める(画面幅が変わっても体感速度が揃う)
      const wanted = Math.round(stageH + travel * 0.9);
      if (Math.abs(wanted - runwayPx.current) > 2) {
        runwayPx.current = wanted;
        runway.style.height = `${wanted}px`;
      }
      const total = Math.max(1, runway.offsetHeight - stageH);
      const p = clamp01(-runway.getBoundingClientRect().top / total);
      stage.style.setProperty("--pw-p", String(p));
      stage.style.setProperty("--pw-x", `${(p * travel).toFixed(2)}px`);
      // ピン留め中にフォーカス送りで内部スクロールが起きたら戻す
      if (track.scrollLeft !== 0) track.scrollLeft = 0;

      // 読んでいる位置は画面中央ではなく少し左(読み始めの側)に置く
      const anchor = p * travel + stage.clientWidth * 0.4;
      const slips = track.children;
      let nearest = 0;
      let best = Infinity;
      for (let i = 0; i < slips.length; i += 1) {
        const el = slips[i] as HTMLElement;
        const d = Math.abs(el.offsetLeft + el.offsetWidth / 2 - anchor);
        if (d < best) {
          best = d;
          nearest = i;
        }
      }
      // 端では必ず最初/最後を指す(幅が不揃いなので距離だけでは端が取れない)
      if (p <= 0.02) nearest = 0;
      else if (p >= 0.98) nearest = slips.length - 1;
      setIndex((prev) => (prev === nearest ? prev : nearest));
    };

    const request = () => {
      if (rafRef.current === null) rafRef.current = requestAnimationFrame(measure);
    };

    const onResize = () => {
      applyTop();
      request();
    };

    applyTop();
    measure();
    window.addEventListener("scroll", request, { passive: true });
    window.addEventListener("resize", onResize);
    const ro =
      typeof ResizeObserver !== "undefined" ? new ResizeObserver(request) : null;
    ro?.observe(track);
    ro?.observe(stage);

    return () => {
      window.removeEventListener("scroll", request);
      window.removeEventListener("resize", onResize);
      ro?.disconnect();
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    };
  }, []);

  // Tab でたどり着いた板を、縦位置の側で中央へ送り返す(ピン留め中の focus 破綻を防ぐ)
  const handleFocusSlip = (i: number) => {
    const runway = runwayRef.current;
    const stage = stageRef.current;
    const track = trackRef.current;
    if (!runway || !stage || !track) return;
    if (
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    )
      return;
    const el = track.children[i] as HTMLElement | undefined;
    if (!el) return;
    const travel = Math.max(1, track.scrollWidth - stage.clientWidth);
    const target = clamp01(
      (el.offsetLeft + el.offsetWidth / 2 - stage.clientWidth / 2) / travel,
    );
    const total = runway.offsetHeight - stage.offsetHeight;
    window.scrollTo({ top: window.scrollY + runway.getBoundingClientRect().top + total * target });
  };

  const current = works[index] ?? works[0];

  return (
    <section ref={sectionRef} aria-labelledby="pw-title" className="pw w-full bg-white">
      <style>{`
        .pw-track { transform: translate3d(calc(var(--pw-x, 0px) * -1), 0, 0); }
        .pw-bar { transform: scaleX(var(--pw-p, 0)); }
        .pw-hint-flat { display: none; }
        /* 平置きへの降格(reduced-motion / 固定する余地の無い入れ物)。マークアップは同じまま */
        .pw-flat .pw-runway { height: auto !important; }
        .pw-flat .pw-stage { position: static !important; height: auto !important; }
        .pw-flat .pw-track {
          transform: none !important;
          overflow-x: auto;
          scroll-snap-type: x mandatory;
          padding-bottom: 12px;
        }
        .pw-flat .pw-slip { scroll-snap-align: center; align-self: auto !important; margin-top: 0 !important; margin-bottom: 0 !important; }
        .pw-flat .pw-bar { transform: scaleX(1); }
        .pw-flat .pw-hint-pinned { display: none; }
        .pw-flat .pw-hint-flat { display: inline; }
        @media (prefers-reduced-motion: reduce) {
          .pw .pw-runway { height: auto !important; }
          .pw .pw-stage { position: static !important; height: auto !important; }
          .pw .pw-track {
            transform: none !important;
            overflow-x: auto;
            scroll-snap-type: x mandatory;
            padding-bottom: 12px;
          }
          .pw .pw-slip { scroll-snap-align: center; align-self: auto !important; margin-top: 0 !important; margin-bottom: 0 !important; }
          .pw .pw-bar { transform: scaleX(1); }
          .pw .pw-hint-pinned { display: none; }
          .pw .pw-hint-flat { display: inline; }
        }
      `}</style>

      {/* 見出し(助走路の外・読み終えたら流れて消える) */}
      <div className="mx-auto max-w-5xl px-5 pt-4 pb-10 sm:px-8">
        <div className="flex items-center gap-3">
          <span aria-hidden="true" className="h-px w-10 bg-amber-600" />
          <span className="text-[13px] font-bold tracking-[0.08em] text-gray-900">実績</span>
        </div>
        <h2
          id="pw-title"
          className="mt-5 text-[clamp(1.6rem,3.6vw,2.3rem)] leading-[1.5] font-bold tracking-tight text-gray-900"
        >
          手を入れた場所と、
          <br />
          変わった数字だけを並べます
        </h2>
        <p className="mt-5 max-w-xl text-[14px] leading-[2] text-gray-600">
          完成画面の見栄えではなく、どこを組み替えて何が動いたか。5件ぶんの台帳をそのまま横へ流します。
        </p>
      </div>

      {/* 助走路(高さはJSが横の走行距離から決める。初期値は SSR 用のフォールバック) */}
      <div
        ref={runwayRef}
        className="pw-runway relative"
        style={{ height: `calc(100svh + ${(works.length - 1) * 58}vh)` }}
      >
        <div
          ref={stageRef}
          className="pw-stage sticky top-[var(--pw-top,0px)] flex h-[calc(100svh-var(--pw-top,0px))] flex-col justify-between overflow-hidden border-y border-gray-200 py-6"
        >
          {/* 状態バー */}
          <div className="flex items-end justify-between gap-6 px-5 sm:px-8">
            <div className="min-w-0">
              <p
                className={`${montserrat.className} text-[11px] font-semibold tracking-[0.12em] text-gray-400 tabular-nums`}
              >
                <span className="text-gray-900">{current.no}</span>
                <span className="px-1 text-gray-300">/</span>
                {String(works.length).padStart(2, "0")}
              </p>
              <p className="mt-2 truncate text-[13px] font-bold text-gray-900">
                {current.kind}
              </p>
            </div>
            <div className="flex w-1/2 max-w-[18rem] items-center gap-3">
              <span aria-hidden="true" className="relative h-px flex-1 bg-gray-200">
                <span className="pw-bar absolute inset-0 h-px origin-left bg-amber-600" />
              </span>
              <span
                className={`${montserrat.className} shrink-0 text-[10px] font-semibold tracking-[0.18em] text-gray-400 tabular-nums`}
              >
                {current.year}
              </span>
            </div>
          </div>

          {/* トラック(核) */}
          <ul
            ref={trackRef}
            className="pw-track flex min-h-0 flex-1 items-center gap-8 px-5 will-change-transform sm:gap-12 sm:px-8"
          >
            {works.map((w, i) => (
              <li
                key={w.no}
                className={`pw-slip group relative flex shrink-0 flex-col border-t border-gray-900/80 pt-5 ${w.width} ${w.offset}`}
                onFocusCapture={() => handleFocusSlip(i)}
                aria-label={`実績 ${w.no} / ${String(works.length).padStart(2, "0")}`}
              >
                <div className="flex items-baseline justify-between gap-4">
                  <span
                    className={`${montserrat.className} text-[clamp(2.4rem,6vw,4rem)] leading-none font-extrabold text-gray-900 tabular-nums`}
                  >
                    {w.no}
                  </span>
                  <span className="text-[11px] font-semibold tracking-[0.14em] text-gray-500">
                    {w.kind}
                  </span>
                </div>

                <h3 className="mt-6 text-[clamp(1.05rem,2.2vw,1.45rem)] leading-[1.7] font-bold whitespace-pre-line text-gray-900">
                  <a
                    href="#"
                    className="text-gray-900 no-underline after:absolute after:inset-0 after:content-[''] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
                  >
                    {w.title}
                  </a>
                </h3>
                <span
                  aria-hidden="true"
                  className="mt-4 h-px w-10 origin-left scale-x-100 bg-amber-600 transition-transform duration-500 ease-out group-hover:scale-x-[3.2] motion-reduce:transition-none"
                />

                <p className="mt-5 max-w-md text-[13px] leading-[2] text-gray-600">{w.lead}</p>

                <dl className="mt-7 flex divide-x divide-gray-200 border-t border-gray-200 pt-4">
                  {w.facts.map((f) => (
                    <div key={f.label} className="min-w-0 flex-1 px-4 first:pl-0 last:pr-0">
                      <dt className="text-[10px] font-semibold tracking-[0.14em] text-gray-400">
                        {f.label}
                      </dt>
                      <dd
                        className={`${montserrat.className} mt-1.5 text-[15px] font-bold text-gray-900 tabular-nums`}
                      >
                        {f.value}
                      </dd>
                    </div>
                  ))}
                </dl>
              </li>
            ))}
          </ul>

          {/* 足元 */}
          <div className="flex items-center justify-between gap-4 px-5 sm:px-8">
            <span
              className={`${montserrat.className} shrink-0 text-[10px] font-semibold tracking-[0.18em] whitespace-nowrap text-gray-400`}
            >
              BLK-57
            </span>
            <span className="text-[11px] font-medium text-gray-500">
              <span className="pw-hint-pinned">そのまま下へスクロールすると横へ進みます</span>
              <span className="pw-hint-flat">横にスクロールすると続きが見られます</span>
            </span>
          </div>
        </div>
      </div>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/pinned-horizontal-works.json