First CH Designs
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

Process

つくる前に、
順番を決める。

ご相談から公開後の運用まで、5つの工程に分けて進めます。いまどこにいて、次に何が必要か。読み進めるだけで分かる形にしました。

0105
進め方の詳細
  1. 01Hearing

    事業と課題を聞く

    何を売っていて、誰に届けたいのか。サイトの話に入る前に、事業そのものを伺います。ここで出た言葉が、後の見出しやコピーの素材になります。

    期間
    約1週間
    成果物
    課題整理メモ
  2. 02Planning

    順番と骨組みを決める

    伝える内容を並べ替え、ページ構成と各ページの骨組みに落とします。デザインの前に文字と順番を固めるので、後戻りが起きません。

    期間
    1〜2週間
    成果物
    サイトマップ/ワイヤー
  3. 03Design

    見え方を確定させる

    骨組みに書体・余白・色を与え、実際の画面として確認していただきます。動きの有無もこの段階で決め、実装の想定と揃えます。

    期間
    約2週間
    成果物
    デザインカンプ
  4. 04Build

    動くものにする

    デザインをコードにし、閲覧できる環境に置きます。表示速度・スマートフォン表示・入力フォームの挙動まで、公開前にひととおり確認します。

    期間
    2〜3週間
    成果物
    確認用サイト
  5. 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