BLK-34Blocks
Process Steps with Sticky Heading
A production-flow block that keeps the heading sticky on the left while each step on the right activates as you read. A vertical progress hairline grows with scroll and lights up each marker as it reaches it. Where supported it runs purely on CSS scroll-driven animations (view-timeline) with zero JavaScript, falling back to a rAF loop that writes the same custom properties.
- Added:
- 2026-08-22
- Dependencies:
- None
- tags
- #process #steps #sticky #scroll #scroll-driven #css-only #section #brand #no-image
Preview
- 01Hearing
事業と課題を聞く
何を売っていて、誰に届けたいのか。サイトの話に入る前に、事業そのものを伺います。ここで出た言葉が、後の見出しやコピーの素材になります。
- 期間
- 約1週間
- 成果物
- 課題整理メモ
- 02Planning
順番と骨組みを決める
伝える内容を並べ替え、ページ構成と各ページの骨組みに落とします。デザインの前に文字と順番を固めるので、後戻りが起きません。
- 期間
- 1〜2週間
- 成果物
- サイトマップ/ワイヤー
- 03Design
見え方を確定させる
骨組みに書体・余白・色を与え、実際の画面として確認していただきます。動きの有無もこの段階で決め、実装の想定と揃えます。
- 期間
- 約2週間
- 成果物
- デザインカンプ
- 04Build
動くものにする
デザインをコードにし、閲覧できる環境に置きます。表示速度・スマートフォン表示・入力フォームの挙動まで、公開前にひととおり確認します。
- 期間
- 2〜3週間
- 成果物
- 確認用サイト
- 05Launch & Care
公開して、育てる
公開はゴールではなく基準点です。数字を見ながら文言や導線を調整し、更新できる状態を保ちます。
- 期間
- 公開後も継続
- 成果物
- 月次レポート
"use client";
import { useEffect, useRef } from "react";
/**
* 制作フロー②スクロール連動ステップ(BLK-34)
*
* 左に見出しを sticky で残し、右の工程リストを読み進みに応じて進捗罫が伸びながら
* 順にアクティブ化する。First CH ブランド(白基調+アンバー #d97706)で構成した独自実装。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **状態を@propertyの数値2本に集約する**: 動きの入力は `--blk34-rp`(リスト全体の進捗 0→1)と
* `--blk34-a`(各工程の到達度 0→1)だけ。罫線は `scaleY(var(--rp))`、工程は
* `opacity: calc(0.4 + 0.6*var(--a))` のように**CSSのcalcで自力計算**する。
* `@property` で `<number>` として登録してあるので、アニメーションでもtransitionでも補間できる。
* - **CSSスクロール駆動が使えるならJSはゼロ**: `view-timeline` + `animation-timeline` で
* `--rp`/`--a` を進める。スクロールイベントを一切張らないのでメインスレッドに何も乗らない。
* - **進捗の基準線を `view-timeline-inset` で作る**: `view-timeline-inset: auto 35%` は
* タイムライン計算上のビューポート下端を35%ぶん上げる=画面の65%地点に基準線を引くのと同じ。
* そのうえで `animation-range: entry-crossing 0% entry-crossing 100%` にすると
* 「対象の上端が基準線を越えた時に0、下端が越えた時に1」になる。要素や画面の高さに依存しないので、
* **罫線が工程の丸印に届いた瞬間にその工程がアクティブになる**という同期が自動的に成立する。
* - **fallbackは同じ変数を書くだけ**: 非対応ブラウザでは rAF で `--rp`/`--a` をインライン指定する。
* 出力先が同じなので見た目のコードは一本のまま(分岐がスタイルに漏れない)。
* - **初期値は「完成状態」**: `initial-value: 1` にしておくと、JSもCSSも効かない環境で
* 本文が薄いまま固定される事故が起きない。演出は上乗せ、情報は常に読める。
* - 動かすのは transform と opacity のみ。`prefers-reduced-motion: reduce` では
* 変数を 1 に固定して「最初から完成している」状態へ倒す。
*/
type Step = {
no: string;
label: string;
title: string;
body: string;
span: string;
deliverable: string;
};
const steps: Step[] = [
{
no: "01",
label: "Hearing",
title: "事業と課題を聞く",
body: "何を売っていて、誰に届けたいのか。サイトの話に入る前に、事業そのものを伺います。ここで出た言葉が、後の見出しやコピーの素材になります。",
span: "約1週間",
deliverable: "課題整理メモ",
},
{
no: "02",
label: "Planning",
title: "順番と骨組みを決める",
body: "伝える内容を並べ替え、ページ構成と各ページの骨組みに落とします。デザインの前に文字と順番を固めるので、後戻りが起きません。",
span: "1〜2週間",
deliverable: "サイトマップ/ワイヤー",
},
{
no: "03",
label: "Design",
title: "見え方を確定させる",
body: "骨組みに書体・余白・色を与え、実際の画面として確認していただきます。動きの有無もこの段階で決め、実装の想定と揃えます。",
span: "約2週間",
deliverable: "デザインカンプ",
},
{
no: "04",
label: "Build",
title: "動くものにする",
body: "デザインをコードにし、閲覧できる環境に置きます。表示速度・スマートフォン表示・入力フォームの挙動まで、公開前にひととおり確認します。",
span: "2〜3週間",
deliverable: "確認用サイト",
},
{
no: "05",
label: "Launch & Care",
title: "公開して、育てる",
body: "公開はゴールではなく基準点です。数字を見ながら文言や導線を調整し、更新できる状態を保ちます。",
span: "公開後も継続",
deliverable: "月次レポート",
},
];
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
export default function ProcessStickySteps() {
const rootRef = useRef<HTMLElement>(null);
const listRef = useRef<HTMLOListElement>(null);
useEffect(() => {
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
// 動きを控える設定: CSS 側が変数を 1 に固定するので JS は何もしない
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// CSSスクロール駆動アニメーションが使えるなら JS は一切動かさない
const cssDriven =
typeof CSS !== "undefined" &&
CSS.supports("animation-timeline: view()") &&
CSS.supports("timeline-scope: --blk34-rail");
if (cssDriven) return;
root.classList.add("blk34-js");
const items = Array.from(list.querySelectorAll<HTMLElement>(".blk34-step"));
let rafId = 0;
let visible = true;
const update = () => {
rafId = 0;
// 基準線は画面の65%地点(CSS側の view-timeline-inset: auto 35% と同じ位置)
const line = (window.innerHeight || 1) * 0.65;
const rect = list.getBoundingClientRect();
root.style.setProperty(
"--blk34-rp",
clamp01((line - rect.top) / Math.max(1, rect.height)).toFixed(4)
);
for (const item of items) {
const r = item.getBoundingClientRect();
const a = clamp01((line - r.top) / Math.max(1, r.height * 0.3));
item.style.setProperty("--blk34-a", a.toFixed(4));
}
};
// scroll は passive・rAFで1フレーム1回に合体させる
const onScroll = () => {
if (!visible || rafId) return;
rafId = requestAnimationFrame(update);
};
// 画面外では計測を止める(出入りの瞬間には必ず1回そろえる)
const io =
typeof IntersectionObserver !== "undefined"
? new IntersectionObserver(
(entries) => {
visible = entries.some((e) => e.isIntersecting);
update();
},
{ rootMargin: "40% 0px" }
)
: null;
io?.observe(list);
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
return () => {
io?.disconnect();
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (rafId) cancelAnimationFrame(rafId);
root.classList.remove("blk34-js");
};
}, []);
return (
<section
ref={rootRef}
aria-labelledby="blk34-heading"
className="blk34 w-full max-w-5xl bg-white"
>
<div className="grid gap-10 md:grid-cols-[16rem_minmax(0,1fr)] md:gap-12 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-16">
{/* 左: 読み進めても残る見出し */}
<div className="md:sticky md:top-16 md:self-start">
<p className="font-display text-[11px] font-bold tracking-[0.28em] text-amber-600 uppercase">
Process
</p>
<h2
id="blk34-heading"
className="mt-4 text-[clamp(1.35rem,2.6vw,1.85rem)] leading-[1.5] font-bold tracking-tight text-gray-900"
>
つくる前に、
<br />
順番を決める。
</h2>
<p className="mt-5 max-w-sm text-[13px] leading-[2] text-gray-600">
ご相談から公開後の運用まで、5つの工程に分けて進めます。いまどこにいて、次に何が必要か。読み進めるだけで分かる形にしました。
</p>
{/* 読み進み量そのものを可視化する目盛り */}
<div className="mt-8 flex items-center gap-3">
<span className="font-display text-[10px] font-bold tracking-[0.15em] text-gray-400 tabular-nums">
01
</span>
<span
aria-hidden="true"
className="relative h-px flex-1 bg-gray-200"
>
<span className="blk34-bar absolute inset-0 block origin-left bg-amber-600" />
</span>
<span className="font-display text-[10px] font-bold tracking-[0.15em] text-gray-400 tabular-nums">
05
</span>
</div>
<a
href="#"
className="group mt-8 inline-flex items-center gap-2 text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
進め方の詳細
<span className="absolute -bottom-1 left-0 h-px w-full origin-left bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
<span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
{/* 右: 工程 */}
<ol ref={listRef} className="blk34-list relative">
{/* 縦の罫線(下地)と、進捗ぶんだけ伸びるアンバーの罫線 */}
<span
aria-hidden="true"
className="absolute top-3 bottom-3 left-[11px] w-px bg-gray-200"
/>
<span
aria-hidden="true"
className="blk34-fill absolute top-3 bottom-3 left-[11px] w-px origin-top bg-amber-600"
/>
{steps.map((step) => (
<li
key={step.no}
className="blk34-step relative pb-12 pl-10 last:pb-0 sm:pl-12"
>
{/* 丸印: 中のアンバーの円が到達度に応じて開く */}
<span
aria-hidden="true"
className="absolute top-1.5 left-0 block size-6 rounded-full border border-gray-200 bg-white"
>
<span className="absolute inset-[4px] block rounded-full bg-gray-200" />
<span className="blk34-mark absolute inset-[4px] block rounded-full bg-amber-600" />
</span>
<div className="blk34-body">
<div className="flex items-center gap-3">
<span className="font-display text-[11px] font-bold tracking-[0.2em] text-gray-900 tabular-nums">
{step.no}
</span>
<span
aria-hidden="true"
className="blk34-tick h-px w-6 origin-left bg-amber-600"
/>
<span className="font-display text-[10px] font-semibold tracking-[0.22em] text-gray-400 uppercase">
{step.label}
</span>
</div>
<h3 className="mt-3 text-[15px] font-bold tracking-tight text-gray-900 sm:text-base">
{step.title}
</h3>
<p className="mt-2 max-w-md text-[13px] leading-[2] text-gray-600">
{step.body}
</p>
<dl className="mt-4 flex max-w-md flex-wrap gap-x-8 gap-y-1 border-t border-gray-100 pt-3 font-mono text-[11px] text-gray-400">
<div className="flex gap-2">
<dt>期間</dt>
<dd className="text-gray-600">{step.span}</dd>
</div>
<div className="flex gap-2">
<dt>成果物</dt>
<dd className="text-gray-600">{step.deliverable}</dd>
</div>
</dl>
</div>
</li>
))}
</ol>
</div>
<style>{`
@property --blk34-rp {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@property --blk34-a {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
@keyframes blk34-rail {
from { --blk34-rp: 0; }
to { --blk34-rp: 1; }
}
@keyframes blk34-activate {
from { --blk34-a: 0; }
to { --blk34-a: 1; }
}
/* 進捗の入れ物。名前をセクション全体から参照できるようにする */
.blk34 { timeline-scope: --blk34-rail; }
.blk34-list {
view-timeline: --blk34-rail block;
view-timeline-inset: auto 35%;
}
/* 出力先: transform と opacity だけを変数から計算する */
.blk34-bar { transform: scaleX(var(--blk34-rp)); }
.blk34-fill { transform: scaleY(var(--blk34-rp)); }
.blk34-mark {
opacity: var(--blk34-a);
transform: scale(calc(0.3 + 0.7 * var(--blk34-a)));
}
.blk34-tick { transform: scaleX(var(--blk34-a)); }
.blk34-body {
opacity: calc(0.4 + 0.6 * var(--blk34-a));
transform: translateY(calc((1 - var(--blk34-a)) * 8px));
}
/* CSSスクロール駆動が使える環境: JSゼロで進める */
@supports (animation-timeline: view()) and (timeline-scope: --blk34-rail) {
.blk34:not(.blk34-js) {
animation: blk34-rail linear both;
animation-timeline: --blk34-rail;
animation-range: entry-crossing 0% entry-crossing 100%;
}
.blk34:not(.blk34-js) .blk34-step {
animation: blk34-activate linear both;
animation-timeline: view(block auto 35%);
animation-range: entry-crossing 0% entry-crossing 30%;
}
}
/* fallback: rAFが同じ変数を書く。到達の瞬間だけ滑らかにする */
.blk34-js .blk34-step { transition: --blk34-a 320ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.blk34, .blk34 .blk34-step { animation: none !important; }
.blk34 { --blk34-rp: 1 !important; }
.blk34 .blk34-step {
--blk34-a: 1 !important;
transition: none !important;
}
}
`}</style>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/process-sticky-steps.json