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.
Preview
公開までの12週間を、
はじめに全部お見せします
標準的な進行の目安です。当社が進める工程と、お客様にご準備いただく工程がどこで並走するかまで含めて、着手前に共有します。
- 公開までの目安
- 12週
- ご準備の期間
- 5週
- 当社が進めます
- ご一緒に進めます
- お客様にご準備いただく期間
お申し込み・着手
ご一緒に第1〜1週/1週間
ご契約内容の確認と体制決め。担当者・連絡手段・確認の締切をこの週に決めます。
ヒアリング
ご一緒に第2〜3週/2週間
事業と課題を伺い、掲載する内容を洗い出します。2回に分けて実施します。
構成・骨組み
当社第3〜4週/2週間
ページの並びと各ページの骨組みを固めます。デザインの前に文字と順番を確定させます。
原稿・写真のご準備
お客様第3〜7週/5週間
掲載する文章と写真をご用意いただく期間。草案はこちらで書き起こすので、赤入れだけで進められます。
デザイン
当社第5〜7週/3週間
骨組みに書体・余白・色を与え、実際の画面としてご確認いただきます。
実装
当社第8〜10週/3週間
デザインをコードにし、確認用の環境に置きます。表示速度とスマートフォン表示もここで整えます。
検証・ご確認
ご一緒に第11〜12週/2週間
全ページの表示確認とフォームの動作確認。修正はこの期間にまとめて反映します。
公開
当社第12〜12週/1週間
ドメインを切り替えて公開します。公開後1か月は初期の不具合対応を含みます。
ご準備の状況や確認のご返信によって前後します。短縮したい事情がある場合は、着手前に工程の組み替えをご相談ください。
スケジュールの詳細を相談するRead the full description
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
"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は英字キッカーと末尾の「→」リンクを外し、角丸も使わない(目盛りと直角のバーで図表に寄せる)。
* 掲載している週数・工程名はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ:
* - **ガント=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