実績・事例(ピン留め横送り台帳)
区間を画面にピン留めし、縦スクロールの進捗をそのまま横移動へ写像する実績セクション。送るための横入力(スワイプ・ドラッグ・矢印ボタン)を一切持たず、ページを読み進めるだけで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
コーポレートサイト
- 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
"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