BLK-68Blocks
導入スケジュール(横タイムライン・期間バー)
お申し込みから公開までを共通の週スケール1本に載せるガント型の導入スケジュール。工程の所要期間はバーの長さ、同時進行はバーの重なりとして読め、担当(当社/ご一緒に/お客様)で塗りを分けるので「お客様の準備がどの工程と並走するか」が位置関係で分かる。週数はすべて等幅数字で揃え、viewport進入時にバーがscaleXで左から伸びる。狭幅では工程ごとの縦積みに切り替わり、週スケールはそのまま保たれる。画像素材ゼロ・依存ゼロ。
- 追加日:
- 2026-09-19
- 依存:
- なし
- tags
- #schedule #timeline #gantt #process #onboarding #section #brand #no-image
プレビュー
公開までの12週間を、
はじめに全部お見せします
標準的な進行の目安です。当社が進める工程と、お客様にご準備いただく工程がどこで並走するかまで含めて、着手前に共有します。
- 公開までの目安
- 12週
- ご準備の期間
- 5週
- 当社が進めます
- ご一緒に進めます
- お客様にご準備いただく期間
1か月目2か月目3か月目
お申し込み・着手
ご一緒に第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か月は初期の不具合対応を含みます。
ご準備の状況や確認のご返信によって前後します。短縮したい事情がある場合は、着手前に工程の組み替えをご相談ください。
スケジュールの詳細を相談する"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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/onboarding-timeline-horizontal.json