Mission Story with a Pinned Backdrop
A three-chapter story section for a company's principles. A pinned 100svh statement stays completely still while an opaque white sheet slides up and covers it, advancing the narrative. Depth comes from occlusion and a receding amber accent instead of shadows: the pinning is pure CSS sticky plus paint order, and JS only writes the occlusion ratio into one custom property. Inside short containers and under reduced motion it degrades to a plain stacked reading piece. No images, no dependencies.
- Added:
- 2026-09-12
- Dependencies:
- None
- tags
- #block #mission #story #about #philosophy #sticky #layered #occlusion #scroll-linked #hairline #no-image #no-dependency
Preview
会社の考え方
実績の数より、
決める順番の話をします
制作会社を選ぶときに本当に知りたいのは、作った数ではなく進め方だと思っています。私たちが守っている順番を、三つに分けて置きました。
事業の言葉を借りる
最初の打ち合わせで、サイトの話はほとんどしません。
伺うのはデザインの好みではなく事業の話です。何を売っていて、誰に届けたいのか。そこで出てきた言い回しを要約せずに書き留め、そのまま見出しと導線の骨に使います。
借りた言葉は最後まで捨てません。罫線を1本引く理由も、色を1つ減らす理由も、事業の言葉で説明できる状態を保ちます。説明がつかない装飾は、この段階で落ちます。
聞いた言葉のまま書かれた見出しは、社内で誰も直しません
- 決めていること
- 聞いた言い回しを、そのままの粒度で残す
- やらないこと
- 先に見た目の方向性を決めてしまう
速さより、順番
早く公開することと、早く成果が出ることは別の話です。
順番を決めないまま手を動かすと、あとから前提が変わって作り直しになります。文字と並び順を先に固め、合意してから絵にするほうが、結局はいちばん速く着きます。
そのぶん、序盤は地味です。画面が出てくるまでに時間がかかるように見えますが、戻りが起きない進め方を選んでいるだけで、工程を飛ばしているわけではありません。
戻りが起きない進み方が、結果としていちばん短い道になります
- 決めていること
- 文字と並び順の合意を、意匠より前に取る
- やらないこと
- 決まる前に画面を作り始める
公開してからが本編
公開日は締め切りではなく、測りはじめる日です。
数字を見て文言を直し、季節で内容を入れ替える。そこまで続けてはじめて、サイトは費用ではなく設備になります。公開の瞬間がいちばん価値の低い状態です。
そのために、引き渡す時点で自分たちで直せる範囲を必ず残します。どこを触ってよくて、どこに触ると崩れるのか。その線引きを書面にして渡すところまでが制作です。
自分たちで直せないサイトは、公開した日がいちばん新しい
- 決めていること
- 更新できる範囲を、書面で線引きして渡す
- やらないこと
- 直せる人がこちらにしかいない形で納める
三つに共通しているのは、決める順番を守ることだけです。特別な手法は使っていません。
会社概要を見る"use client";
import { useEffect, useRef } from "react";
import { Montserrat } from "next/font/google";
// ラテン系フォントは unicode-range の分割が少ないので preload 既定のままでよい。
const montserrat = Montserrat({
weight: ["600"],
subsets: ["latin"],
display: "swap",
});
/**
* ミッション・ストーリー(背面レイヤー×前景シートの層構成)
*
* 差別化: 既存のスクロール連動ブロックはいずれも「1枚の面の中で要素が動く」型(ピン留めした舞台を
* 横へ送る/見出しを脇に残して工程を進める/読み進み量で文字を出現させる)だが、本ブロックは面を
* 2枚に分け、奥の一文は一切動かさない。手前の白いシートがその上を滑って覆っていくことだけで
* 章が進む。奥行きは影を使わず、遮蔽と彩度の後退だけで作る。
* 飽和していた英字キッカー・末尾の矢印リンク・角丸・要素ごとの出現アニメは意図的に外した。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **「覆う」は積み順だけで作れる**: 章の <div> に `sticky top-0 h-[100svh]` の奥レイヤーと、
* その後ろへ続く不透明な白いシートを置く。親(章)はシートのぶんだけ背が高いので、奥レイヤーは
* 貼り付いたまま、あとから流れてくるシートに上から塗り潰されていく。JS は固定にも遮蔽にも
* 関与しない(スクロール量で position を切り替えると必ずガタつく)。
* - **奥行きの手掛かりは影ではなく2つ**: ①遮蔽(手前の不透明な面が奥の面を欠けさせる)
* ②彩度の後退。アンバーの罫だけを速い係数 `1 - 1.8p` で抜き、墨の文字は緩い係数 `1 - 0.55p` で
* 残すと「色が先に遠ざかり、輪郭が後から沈む」という空気遠近と同じ順序になる。ぼかしは使わない
* (合成が重いうえ、hairline の1pxが最初に溶けて構造そのものが失われる)。
* - **進捗は奥ではなく「覆う側の上端」で測る**: `p = (vh - sheet.top) / vh`。遮蔽の量そのものを
* 測っているので、章の高さやシートの長さを変えても係数を調整し直さなくてよい。
* - **貼り付いた天井の扱いは配置で解く**: 奥の一文を上端揃えではなく上下中央に置くと、固定ヘッダーの
* あるサイトへ落としても主役の一文が潜らない。ヘッダー高を実測して top を補正する必要が無くなる。
* - **短い箱に埋め込まれたら固定をやめる**: 直近の overflow を持つ祖先が1画面ぶんの高さを持たない
* ときは position:static へ降格し、素の縦積みにする(`transform: scale()` した中で描かれると、
* sticky は縮小後の座標を基準に貼り付いてレイアウトが崩れるため)。動きを控える設定でも同じ形へ
* 倒す。覆う演出が一度も走らなくても、章と本文の読み物として完結する。
*/
type Chapter = {
no: string;
label: string;
/** 奥に貼り付く一文。句点で止めない */
message: string;
/** 一文に添える短い補足 */
aside: string;
lead: string;
body: string;
/** 面の中ほどに置く一文。本文の要約ではなく、言い切りを1つ */
pull: string;
keeps: string;
avoids: string;
};
const chapters: Chapter[] = [
{
no: "01",
label: "はじめの一時間",
message: "事業の言葉を借りる",
aside: "最初の打ち合わせで、サイトの話はほとんどしません。",
lead: "伺うのはデザインの好みではなく事業の話です。何を売っていて、誰に届けたいのか。そこで出てきた言い回しを要約せずに書き留め、そのまま見出しと導線の骨に使います。",
body: "借りた言葉は最後まで捨てません。罫線を1本引く理由も、色を1つ減らす理由も、事業の言葉で説明できる状態を保ちます。説明がつかない装飾は、この段階で落ちます。",
pull: "聞いた言葉のまま書かれた見出しは、社内で誰も直しません",
keeps: "聞いた言い回しを、そのままの粒度で残す",
avoids: "先に見た目の方向性を決めてしまう",
},
{
no: "02",
label: "つくる順番",
message: "速さより、順番",
aside: "早く公開することと、早く成果が出ることは別の話です。",
lead: "順番を決めないまま手を動かすと、あとから前提が変わって作り直しになります。文字と並び順を先に固め、合意してから絵にするほうが、結局はいちばん速く着きます。",
body: "そのぶん、序盤は地味です。画面が出てくるまでに時間がかかるように見えますが、戻りが起きない進め方を選んでいるだけで、工程を飛ばしているわけではありません。",
pull: "戻りが起きない進み方が、結果としていちばん短い道になります",
keeps: "文字と並び順の合意を、意匠より前に取る",
avoids: "決まる前に画面を作り始める",
},
{
no: "03",
label: "公開したあと",
message: "公開してからが本編",
aside: "公開日は締め切りではなく、測りはじめる日です。",
lead: "数字を見て文言を直し、季節で内容を入れ替える。そこまで続けてはじめて、サイトは費用ではなく設備になります。公開の瞬間がいちばん価値の低い状態です。",
body: "そのために、引き渡す時点で自分たちで直せる範囲を必ず残します。どこを触ってよくて、どこに触ると崩れるのか。その線引きを書面にして渡すところまでが制作です。",
pull: "自分たちで直せないサイトは、公開した日がいちばん新しい",
keeps: "更新できる範囲を、書面で線引きして渡す",
avoids: "直せる人がこちらにしかいない形で納める",
},
];
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** 直近の「切り取られる箱」を探す(ページそのものは対象外) */
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 StickyBackdropStory() {
const sectionRef = useRef<HTMLElement | null>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
// 動きを控える設定では CSS 側が平置きへ倒すので、JS は何もしない
if (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
const chapterEls = Array.from(section.querySelectorAll<HTMLElement>(".sb-chapter"));
const first = chapterEls[0];
if (!first) return;
// 固定する余地の無い箱(一覧カード等)へ埋め込まれているときは平置きへ降格する
const back = first.querySelector<HTMLElement>(".sb-back");
const clipper = findClipper(section);
if (
back &&
clipper &&
clipper.clientHeight > 0 &&
clipper.clientHeight < back.offsetHeight * 0.8
) {
section.classList.add("sb-flat");
return;
}
const sheets = chapterEls.map((c) => c.querySelector<HTMLElement>(".sb-sheet"));
const measure = () => {
rafRef.current = null;
const vh = window.innerHeight || 1;
const rect = section.getBoundingClientRect();
// 画面外では書かない(端に到達した値がそのまま残るので、戻ってきても破綻しない)
if (rect.bottom < 0 || rect.top > vh) return;
chapterEls.forEach((chapter, i) => {
const sheet = sheets[i];
if (!sheet) return;
// 覆う側の上端が画面下端にあるとき 0、画面上端まで来たとき 1
const p = clamp01((vh - sheet.getBoundingClientRect().top) / vh);
chapter.style.setProperty("--sb-p", p.toFixed(3));
});
};
const request = () => {
if (rafRef.current === null) rafRef.current = requestAnimationFrame(measure);
};
measure();
window.addEventListener("scroll", request, { passive: true });
window.addEventListener("resize", request);
return () => {
window.removeEventListener("scroll", request);
window.removeEventListener("resize", request);
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, []);
return (
<section
ref={sectionRef}
aria-labelledby="sb-title"
className="sb w-full bg-white text-gray-900"
>
<style>{`
.sb-chapter { --sb-p: 0; }
/* 出力は transform と opacity だけ。奥の面は動かさず、沈むだけにする */
.sb-recede {
transform: translateY(calc(var(--sb-p) * 18px)) scale(calc(1 - 0.055 * var(--sb-p)));
transform-origin: 50% 40%;
will-change: transform;
}
.sb-ink { opacity: calc(1 - 0.55 * var(--sb-p)); }
/* 色は輪郭より先に遠ざかる(彩度の後退) */
.sb-accent { opacity: calc(1 - 1.8 * var(--sb-p)); }
/* 平置きへの降格。マークアップは同じまま、固定と遮蔽だけを外す */
.sb-flat .sb-back {
position: static;
height: auto;
padding-top: 4.5rem;
padding-bottom: 3rem;
}
.sb-flat .sb-sheet { min-height: 0; }
.sb-flat .sb-recede { transform: none; }
.sb-flat .sb-ink, .sb-flat .sb-accent { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.sb .sb-back {
position: static;
height: auto;
padding-top: 4.5rem;
padding-bottom: 3rem;
}
.sb .sb-sheet { min-height: 0; }
.sb .sb-recede { transform: none; }
.sb .sb-ink, .sb .sb-accent { opacity: 1; }
}
`}</style>
{/* 導入(層の外側。読み終えたら普通に流れて消える) */}
<div className="mx-auto max-w-3xl px-5 pt-2 pb-12 sm:px-8">
<p className="flex items-center gap-3 text-[12px] font-bold tracking-[0.1em] text-gray-500">
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
会社の考え方
</p>
<h2
id="sb-title"
className="mt-5 text-[clamp(1.55rem,3.4vw,2.2rem)] leading-[1.5] font-bold tracking-tight"
>
実績の数より、
<br />
決める順番の話をします
</h2>
<p className="mt-5 max-w-xl text-[14px] leading-[2] text-gray-600">
制作会社を選ぶときに本当に知りたいのは、作った数ではなく進め方だと思っています。私たちが守っている順番を、三つに分けて置きました。
</p>
</div>
{chapters.map((chapter) => (
<div key={chapter.no} className="sb-chapter relative isolate">
{/* 奥の面: 貼り付いたまま動かない。中央寄せなので固定ヘッダーに潜らない */}
<div className="sb-back sticky top-0 z-0 flex h-[100svh] items-center">
<div className="sb-recede mx-auto w-full max-w-3xl px-5 sm:px-8">
<div className="flex items-center gap-4">
<span aria-hidden="true" className="sb-accent h-px w-12 shrink-0 bg-amber-600" />
<span
className={`${montserrat.className} sb-accent text-[11px] font-semibold tracking-[0.18em] text-amber-700 tabular-nums`}
>
{chapter.no}
</span>
<span className="sb-ink text-[11px] font-bold tracking-[0.12em] text-gray-500">
{chapter.label}
</span>
</div>
{/* 折り返しの質を2つで担保する: text-balance で行長を揃え、auto-phrase で
文節の途中(「言|葉」等)に改行が落ちるのを防ぐ(非対応ブラウザは素の折り返し) */}
<h3 className="sb-ink mt-7 text-[clamp(1.75rem,5.2vw,3rem)] leading-[1.4] font-bold tracking-tight text-balance [word-break:auto-phrase]">
{chapter.message}
</h3>
<p className="sb-ink mt-7 max-w-md text-[13px] leading-[2] text-gray-500">
{chapter.aside}
</p>
</div>
</div>
{/* 手前の面: 不透明な白。これが奥の面を上から欠けさせる(影は置かない) */}
<div className="sb-sheet relative z-10 flex min-h-[88svh] flex-col justify-between gap-14 border-t border-gray-200 bg-white">
<span
aria-hidden="true"
className="absolute top-0 left-0 h-px w-16 bg-amber-600 sm:w-24"
/>
{/* 上: 覆う境界のすぐ下から本文が始まる */}
<div className="@container mx-auto w-full max-w-3xl px-5 pt-12 sm:px-8">
<div className="grid gap-6 @2xl:grid-cols-[8rem_minmax(0,1fr)] @2xl:gap-12">
<div className="flex items-baseline gap-3 @2xl:block">
<span
className={`${montserrat.className} text-[11px] font-semibold tracking-[0.18em] text-gray-400 tabular-nums`}
>
{chapter.no}
</span>
<span className="text-[12px] font-bold tracking-[0.08em] whitespace-nowrap text-gray-500 @2xl:mt-2.5 @2xl:block">
{chapter.label}
</span>
</div>
<div className="min-w-0">
<p className="text-[14px] leading-[2] font-medium text-gray-800">{chapter.lead}</p>
<p className="mt-5 text-[13.5px] leading-[2] text-gray-600">{chapter.body}</p>
</div>
</div>
</div>
{/* 中: 言い切りを1つ。面の真ん中に重さを置き、余白を「間」として成立させる */}
<div className="mx-auto w-full max-w-3xl px-5 sm:px-8">
<p className="max-w-2xl border-l-2 border-amber-600 pl-6 text-[clamp(1.05rem,2.1vw,1.35rem)] leading-[1.9] font-bold tracking-tight text-gray-900">
{chapter.pull}
</p>
</div>
{/* 下: 章の結論を面の下端に置き、余白を上下の内容で挟む */}
<div className="@container mx-auto w-full max-w-3xl px-5 pb-14 sm:px-8">
<dl className="grid gap-x-12 gap-y-5 border-t border-gray-200 pt-5 @2xl:grid-cols-2">
<div className="@2xl:border-r @2xl:border-gray-200 @2xl:pr-12">
<dt className="text-[11px] font-bold tracking-[0.08em] text-amber-700">
決めていること
</dt>
<dd className="mt-2 text-[13px] leading-[1.9] text-gray-700">{chapter.keeps}</dd>
</div>
<div>
<dt className="text-[11px] font-bold tracking-[0.08em] text-gray-400">
やらないこと
</dt>
<dd className="mt-2 text-[13px] leading-[1.9] text-gray-700">{chapter.avoids}</dd>
</div>
</dl>
</div>
</div>
</div>
))}
{/* 結び */}
<div className="mx-auto max-w-3xl px-5 pt-12 pb-2 sm:px-8">
<p className="max-w-xl text-[14px] leading-[2] text-gray-600">
三つに共通しているのは、決める順番を守ることだけです。特別な手法は使っていません。
</p>
<a
href="#"
className="mt-6 inline-block text-[13px] font-semibold text-gray-900 underline decoration-gray-300 decoration-1 underline-offset-[7px] transition-colors duration-200 hover:decoration-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
会社概要を見る
</a>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/sticky-backdrop-story.json