First CH Designs
BLK-57Blocks

Works — Pinned Horizontal Ledger

A works section that pins itself to the viewport and maps vertical scroll progress straight onto horizontal travel. It has no horizontal input at all — no swipe, drag or arrow buttons — you simply keep reading and five ledger slips pass from right to left. The pinning is done by a tall runway plus a sticky stage, not by JavaScript; JS only reads progress and writes two custom properties, --pw-x (px) and --pw-p (0–1), so the only output is a transform and React state updates just for the counter. The runway height is re-derived from the actual horizontal overflow, keeping vertical and horizontal pace consistent at any viewport width. Each slip differs in width and vertical offset and carries an index, a type, one sentence and three hard numbers. Because focus traversal would otherwise scroll the pinned ancestor and break the transform, focus is intercepted and the page is scrolled to the vertical position that centres that slip. Under prefers-reduced-motion — or when embedded in a box too short to pin — it degrades to a plain scroll-snap horizontal scroller with identical markup. Zero images, zero dependencies, no rounded corners.

Added:
2026-09-11
Dependencies:
None
tags
#block #works #case-study #pinned #sticky #scroll-linked #horizontal #ledger #no-image #no-dependency

Preview

実績

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

完成画面の見栄えではなく、どこを組み替えて何が動いたか。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>
  );
}

Add to your project via shadcn CLI

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