First CH Designs
BLK-68Blocks

Onboarding Schedule — Horizontal Gantt Timeline

A Gantt-style onboarding schedule that places every phase on one shared 12-week scale: duration reads as bar length and parallel work as overlapping bars, with fills distinguishing who owns each phase. Week figures are tabular-nums, bars grow from the left with scaleX on viewport entry, and the layout stacks per phase on narrow screens while keeping the same scale. No images, no dependencies.

Added:
2026-09-19
Dependencies:
None
tags
#schedule #timeline #gantt #process #onboarding #section #brand #no-image

Preview

公開までの12週間を、
はじめに全部お見せします

標準的な進行の目安です。当社が進める工程と、お客様にご準備いただく工程がどこで並走するかまで含めて、着手前に共有します。

公開までの目安
12
ご準備の期間
5
  • 当社が進めます
  • ご一緒に進めます
  • お客様にご準備いただく期間
1か月目2か月目3か月目
  1. お申し込み・着手

    ご一緒に

    11/1週間

    ご契約内容の確認と体制決め。担当者・連絡手段・確認の締切をこの週に決めます。

  2. ヒアリング

    ご一緒に

    23/2週間

    事業と課題を伺い、掲載する内容を洗い出します。2回に分けて実施します。

  3. 構成・骨組み

    当社

    34/2週間

    ページの並びと各ページの骨組みを固めます。デザインの前に文字と順番を確定させます。

  4. 原稿・写真のご準備

    お客様

    37/5週間

    掲載する文章と写真をご用意いただく期間。草案はこちらで書き起こすので、赤入れだけで進められます。

  5. デザイン

    当社

    57/3週間

    骨組みに書体・余白・色を与え、実際の画面としてご確認いただきます。

  6. 実装

    当社

    810/3週間

    デザインをコードにし、確認用の環境に置きます。表示速度とスマートフォン表示もここで整えます。

  7. 検証・ご確認

    ご一緒に

    1112/2週間

    全ページの表示確認とフォームの動作確認。修正はこの期間にまとめて反映します。

  8. 公開

    当社

    1212/1週間

    ドメインを切り替えて公開します。公開後1か月は初期の不具合対応を含みます。

ご準備の状況や確認のご返信によって前後します。短縮したい事情がある場合は、着手前に工程の組み替えをご相談ください。

スケジュールの詳細を相談する
"use client";

import { useEffect, useRef } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["500", "600"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 導入スケジュール(横タイムライン・期間バー)
 * 既存の工程系は「順番だけを見せる型」(等間隔の番号ステップ/読み進みで1件ずつ立ち上がる縦リスト)
 * が既出で、所要期間は本文に文字で添えるだけだった。本ブロックは全工程を**共通の週スケール1本**に載せ、
 * 期間=バーの長さ・同時進行=バーの重なりとして読ませるガント型にした。担当(当社/お客様)で
 * バーの塗りを分け、「お客様の準備はどの工程と並走するか」が位置関係で分かることが主目的。
 * 飽和idiomは英字キッカーと末尾の「→」リンクを外し、角丸も使わない(目盛りと直角のバーで図表に寄せる)。
 * 掲載している週数・工程名はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **ガント=1本のスケールを全行で共有する**: バーの配置は grid の列span ではなく
 *   `left:(start-1)/W*100%` / `width:span/W*100%` の%で持つ。行の内側だけで完結するので、
 *   狭幅で行が縦積み(テキスト↑/トラック↓)に変わってもスケールがそのまま生き、
 *   広幅レイアウト用の列定義を二重に持たなくて済む(DOMは1つ・レイアウトだけ変える)。
 * - **目盛りは行ごとに敷く**: トラックの中に `absolute inset-0 grid grid-cols-12` の hairline 層を置き、
 *   月境界だけ gray-200・週境界は gray-100 と濃淡を分ける。図表全体に1枚の背景を敷くより、
 *   行の増減で位置合わせが壊れない。
 * - **伸長は scaleX + origin-left の1本だけ**: width をアニメーションさせるとレイアウトが毎フレーム動く。
 *   `scale-x-0 → scale-x-100` なら合成だけで済み、バー内の文字を持たない図形と相性が良い。
 *   スタッガーは index*70ms の transitionDelay をインラインで渡す(CSSに行数を書かない)。
 * - **出現トリガは1回だけ・状態は DOM 属性に置く**: IntersectionObserver は root 1個に張り、交差したら
 *   root に `data-shown` を付けて unobserve。バー側は `group-data-[shown]:scale-x-100` で受けるので
 *   setState による再描画が一切起きない(行が増えてもコストが乗らない)。
 *   `prefers-reduced-motion: reduce` の環境では effect の先頭で属性を付けて observer を作らない
 *   (「止める」ではなく「最初から伸び切っている」)。
 * - **期間の数字は tabular-nums で列を揃える**: 「第3〜7週/5週間」のような可変長の数字が縦に並ぶ場所は
 *   等幅数字にしないと行ごとに桁がずれて表に見えない。
 */

/** スケール全体の週数。バーの%はすべてこの値を基準に算出する */
const TOTAL_WEEKS = 12;

type Owner = "当社" | "お客様" | "ご一緒に";

type Phase = {
  name: string;
  owner: Owner;
  /** 開始週(1始まり) */
  start: number;
  /** 所要週数 */
  span: number;
  detail: string;
  /** 節目の工程。バー右端にダイヤのマーカーを立てる */
  milestone?: boolean;
};

const phases: Phase[] = [
  {
    name: "お申し込み・着手",
    owner: "ご一緒に",
    start: 1,
    span: 1,
    detail: "ご契約内容の確認と体制決め。担当者・連絡手段・確認の締切をこの週に決めます。",
  },
  {
    name: "ヒアリング",
    owner: "ご一緒に",
    start: 2,
    span: 2,
    detail: "事業と課題を伺い、掲載する内容を洗い出します。2回に分けて実施します。",
  },
  {
    name: "構成・骨組み",
    owner: "当社",
    start: 3,
    span: 2,
    detail: "ページの並びと各ページの骨組みを固めます。デザインの前に文字と順番を確定させます。",
  },
  {
    name: "原稿・写真のご準備",
    owner: "お客様",
    start: 3,
    span: 5,
    detail: "掲載する文章と写真をご用意いただく期間。草案はこちらで書き起こすので、赤入れだけで進められます。",
  },
  {
    name: "デザイン",
    owner: "当社",
    start: 5,
    span: 3,
    detail: "骨組みに書体・余白・色を与え、実際の画面としてご確認いただきます。",
  },
  {
    name: "実装",
    owner: "当社",
    start: 8,
    span: 3,
    detail: "デザインをコードにし、確認用の環境に置きます。表示速度とスマートフォン表示もここで整えます。",
  },
  {
    name: "検証・ご確認",
    owner: "ご一緒に",
    start: 11,
    span: 2,
    detail: "全ページの表示確認とフォームの動作確認。修正はこの期間にまとめて反映します。",
  },
  {
    name: "公開",
    owner: "当社",
    start: 12,
    span: 1,
    detail: "ドメインを切り替えて公開します。公開後1か月は初期の不具合対応を含みます。",
    milestone: true,
  },
];

/** 担当ごとのバーの見え方。塗り分けの根拠を1か所に持つ */
const barStyle: Record<Owner, string> = {
  当社: "bg-amber-600",
  ご一緒に: "bg-amber-600/30",
  お客様: "border border-amber-600 bg-white",
};

/** お客様にご準備いただく週数の合計(重複期間はそのまま足さず、最長の1本を使う) */
const clientWeeks = Math.max(...phases.filter((p) => p.owner === "お客様").map((p) => p.span));

const stripe =
  "repeating-linear-gradient(45deg, rgba(217,119,6,0.55) 0 1px, transparent 1px 5px)";

export default function OnboardingTimelineHorizontal() {
  const rootRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    // 状態は DOM 属性1つに置く(setState しないので行が増えても再描画コストゼロ)
    const show = () => root.setAttribute("data-shown", "");
    // reduced-motion では観測せず、最初から伸び切った状態にする
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      show();
      return;
    }
    const io = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (!entry.isIntersecting) continue;
          show();
          io.unobserve(entry.target);
        }
      },
      // 節がビューポートより高くなり得るので、比率ではなく「下端から15%内側に入ったら」で判定する
      { rootMargin: "0px 0px -15% 0px" },
    );
    io.observe(root);
    return () => io.disconnect();
  }, []);

  return (
    <section
      ref={rootRef}
      aria-labelledby="onboarding-schedule-heading"
      className="group @container w-full max-w-5xl bg-white px-5 py-10 text-gray-900 sm:px-8 sm:py-14"
    >
      <div className="@2xl:flex @2xl:items-end @2xl:justify-between @2xl:gap-10">
        <div className="max-w-xl">
          <h2
            id="onboarding-schedule-heading"
            className="text-[clamp(1.35rem,3.4vw,1.9rem)] leading-[1.5] font-bold tracking-tight"
          >
            公開までの12週間を、
            <br />
            はじめに全部お見せします
          </h2>
          {/* 和文は改行で空きが入るのでソース上も1行で置く */}
          <p className="mt-4 text-[13px] leading-[2] text-gray-600">
            標準的な進行の目安です。当社が進める工程と、お客様にご準備いただく工程がどこで並走するかまで含めて、着手前に共有します。
          </p>
        </div>

        <dl className="mt-6 flex gap-8 border-t border-gray-200 pt-4 @2xl:mt-0 @2xl:shrink-0 @2xl:border-t-0 @2xl:pt-0">
          <div>
            <dt className="text-[11px] text-gray-500">公開までの目安</dt>
            <dd className={`${montserrat.className} mt-1 text-[1.75rem] leading-none font-semibold tabular-nums`}>
              {TOTAL_WEEKS}
              <span className="ml-1 text-[13px] font-medium text-gray-500">週</span>
            </dd>
          </div>
          <div>
            <dt className="text-[11px] text-gray-500">ご準備の期間</dt>
            <dd className={`${montserrat.className} mt-1 text-[1.75rem] leading-none font-semibold tabular-nums`}>
              {clientWeeks}
              <span className="ml-1 text-[13px] font-medium text-gray-500">週</span>
            </dd>
          </div>
        </dl>
      </div>

      {/* 凡例。バーの塗りの意味を先に置く */}
      <ul className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-gray-200 pt-4 text-[11px] text-gray-600">
        {(["当社", "ご一緒に", "お客様"] as Owner[]).map((owner) => (
          <li key={owner} className="flex items-center gap-2">
            <span
              aria-hidden="true"
              className={`h-2.5 w-6 ${barStyle[owner]}`}
              style={owner === "お客様" ? { backgroundImage: stripe } : undefined}
            />
            {owner === "お客様" ? "お客様にご準備いただく期間" : owner === "当社" ? "当社が進めます" : "ご一緒に進めます"}
          </li>
        ))}
      </ul>

      {/* 週の目盛り(見出し行)。狭幅では月のまとまりだけ、広幅では週番号まで出す */}
      <div className="mt-8 @2xl:grid @2xl:grid-cols-[11rem_minmax(0,1fr)] @2xl:gap-6">
        <div className="hidden @2xl:block" />
        <div>
          <div className="grid grid-cols-3 text-[10px] text-gray-500">
            {["1か月目", "2か月目", "3か月目"].map((month, i) => (
              <span key={month} className={`pb-1 pl-2 ${i === 0 ? "" : "border-l border-gray-200"}`}>
                {month}
              </span>
            ))}
          </div>
          <div
            className={`${montserrat.className} hidden grid-cols-12 border-t border-gray-300 tabular-nums @2xl:grid`}
            aria-hidden="true"
          >
            {Array.from({ length: TOTAL_WEEKS }, (_, i) => (
              <span
                key={i}
                className={`pt-1.5 pl-1.5 text-[10px] text-gray-400 ${
                  i === 0 ? "" : i % 4 === 0 ? "border-l border-gray-200" : "border-l border-gray-100"
                }`}
              >
                {i + 1}
              </span>
            ))}
          </div>
          <div className="h-px w-full bg-gray-300 @2xl:hidden" />
        </div>
      </div>

      <ol className="mt-1">
        {phases.map((phase, i) => {
          const end = phase.start + phase.span - 1;
          return (
            <li
              key={phase.name}
              className="grid border-b border-gray-100 py-4 @2xl:grid-cols-[11rem_minmax(0,1fr)] @2xl:gap-x-6"
            >
              <div className="@2xl:row-span-2">
                <div className="flex items-baseline gap-2">
                  <h3 className="text-[13px] font-bold">{phase.name}</h3>
                  <span className="text-[10px] text-gray-400">{phase.owner}</span>
                </div>
                <p className={`${montserrat.className} mt-1 text-[11px] tabular-nums text-gray-500`}>
                  第{phase.start}〜{end}週
                  <span className="mx-1.5 text-gray-300">/</span>
                  {phase.span}週間
                </p>
              </div>

              {/* 期間バー。位置と幅は共通スケールの% で持つ */}
              <div className="relative mt-3 h-6 @2xl:mt-0">
                <span aria-hidden="true" className="absolute inset-0 grid grid-cols-12">
                  {Array.from({ length: TOTAL_WEEKS }, (_, w) => (
                    <span
                      key={w}
                      className={
                        w === 0 ? "" : w % 4 === 0 ? "border-l border-gray-200" : "border-l border-gray-100"
                      }
                    />
                  ))}
                </span>
                <span
                  className="absolute top-1/2 -translate-y-1/2"
                  style={{
                    left: `${((phase.start - 1) / TOTAL_WEEKS) * 100}%`,
                    width: `${(phase.span / TOTAL_WEEKS) * 100}%`,
                  }}
                >
                  <span
                    className={`block h-2.5 origin-left scale-x-0 transition-transform duration-700 ease-out group-data-[shown]:scale-x-100 motion-reduce:scale-x-100 motion-reduce:transition-none ${
                      barStyle[phase.owner]
                    }`}
                    style={{
                      transitionDelay: `${i * 70}ms`,
                      ...(phase.owner === "お客様" ? { backgroundImage: stripe } : null),
                    }}
                  />
                </span>
                {/* 節目は縦のゴールラインで示す。バーの端に図形を足すと矢印に見えてしまうので離す */}
                {phase.milestone && (
                  <span
                    aria-hidden="true"
                    className="absolute inset-y-0 w-0 opacity-0 transition-opacity duration-500 ease-out group-data-[shown]:opacity-100 motion-reduce:opacity-100 motion-reduce:transition-none"
                    style={{
                      left: `${(end / TOTAL_WEEKS) * 100}%`,
                      transitionDelay: `${i * 70 + 240}ms`,
                    }}
                  >
                    <span className="absolute top-0 size-1.5 -translate-x-1/2 rotate-45 bg-amber-600" />
                    <span className="absolute inset-y-0 w-px -translate-x-1/2 bg-amber-600/50" />
                  </span>
                )}
              </div>

              <p className="mt-2 max-w-md text-[11px] leading-[1.9] text-gray-500 @2xl:col-start-2 @2xl:mt-2">
                {phase.detail}
              </p>
            </li>
          );
        })}
      </ol>

      <div className="mt-6 flex flex-col gap-4 text-[11px] leading-[1.9] text-gray-500 @2xl:flex-row @2xl:items-center @2xl:justify-between">
        <p>ご準備の状況や確認のご返信によって前後します。短縮したい事情がある場合は、着手前に工程の組み替えをご相談ください。</p>
        <a
          href="#"
          className="group relative shrink-0 self-start text-[13px] font-bold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 @2xl:self-auto"
        >
          スケジュールの詳細を相談する
          <span
            aria-hidden="true"
            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
            aria-hidden="true"
            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"
          />
        </a>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

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