Day-in-the-Life Timetable (Recruiting)
A recruiting block that lays two employees' days side by side on one shared time axis. Each 30-minute slot is one row of a CSS grid and every task is placed with grid-row, with fixed row heights, so block height is proportional to duration and both columns stay aligned. Thirty-minute tasks collapse to one line, and notes that do not fit are shown next to the time slider instead. Times are right-aligned Montserrat tabular numerals, and each task carries a one-line comment. A native range slider scrubs through the day: an amber band moves to that row, the active tasks draw their left rule with scale-y, and what each person is doing is announced via aria-live. A square-cornered bar and tabular figures compare how the day splits into focus, talk, tidy-up and break. On narrow screens it collapses to one column with a person toggle. No rounded corners, uppercase kicker, arrow links or scroll reveals — just hairlines and whitespace. Zero images, zero dependencies; all people and times are fictional samples.
- Added:
- 2026-09-28
- Dependencies:
- None
- tags
- #recruit #timeline #schedule #day-in-the-life #range-slider #tabular-nums #css-grid #no-image
Preview
社員の1日/ある水曜日
ふたりの時間割を、
同じ時計で見る
職種の違うふたりの1日を、同じ時刻の軸に並べました。予定の縦の長さはかかった時間に比例します。つまみを動かすと、その時刻にそれぞれが何をしているかが分かります(時刻と内容は架空のサンプルです)
- ディレクター
- 要件の整理先週のヒアリングを、画面の単位に割り直す時間
- エンジニア
- 実装午前は通知を切って、部品を1つ仕上げる
Webディレクター・在籍3年・週2日出社
- 9:00–9:30始業・メール整理今日決めることを3つだけ書き出してから始めます
- 9:30–10:00朝会15分で切り上げ、残りは相談の時間に回します
- 10:00–12:00要件の整理先週のヒアリングを、画面の単位に割り直す時間
- 12:00–13:00昼休憩近所を一周してから戻ります
- 13:00–14:00お客さまと定例決まったことは、その場で議事録に書いて共有
- 14:00–15:30ワイヤーの読み合わせ実装担当と画面を見ながら、崩れ方まで決めます
- 15:30–17:30進行表と見積の更新約束した日付を、根拠と一緒に書き直す
- 17:30–18:00日報明日の自分への申し送りを書いて終わり
1日の内訳
- ひとりで集中
- 人と話す
- 整える
- 休憩
ディレクター9.0h
- ひとりで集中
- 4.0h
- 人と話す
- 3.0h
- 整える
- 1.0h
- 休憩
- 1.0h
エンジニア9.5h
- ひとりで集中
- 5.5h
- 人と話す
- 2.5h
- 整える
- 0.5h
- 休憩
- 1.0h
出社する曜日と始業の時刻は、チームと相談して自分で決めています
"use client";
import { useId, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 社員の1日タイムライン(2職種を同じ時刻軸に並べる時間割・BLK-83)
*
* 差別化: 既存の採用ブロック(recruit-teaser / recruit-requirements-table)は「人の気配と要約」
* 「条件の書類」、沿革(history-timeline)は年単位の出来事の羅列。本ブロックは所要時間を縦の長さに
* 比例させた1日の時間割で、職種の違うふたりを同じ時刻軸に並べ、時刻つまみで"その瞬間"を比べさせる。
*
* 意図的に外した型(ギャラリー全体で飽和していたもの):
* - 英字キッカー(大文字+広い字間)を置かない。見出しの上は和文の小さな日付ラベルだけ
* - 行末の矢印リンクを使わない。CTAは矢印なしの直角ボタン1本
* - 角丸をゼロにする(凡例の色見本も内訳バーも直角)
* - スクロール連動の出現アニメーションを持たない。動くのは時刻つまみに応じた合図だけ
*
* 汎用技法メモ:
*
* 1. 2本のタイムラインを「1つの CSS grid の行」で揃える
* 30分を1行にした grid-template-rows: repeat(N, 2.75rem) に、両者の予定を
* grid-row: 開始行 / 終了行 で置く。行の高さは固定にする——auto にすると説明文のある
* 短い予定だけ行が伸び、30分と2時間がほぼ同じ長さに見えて「長さ=時間」が崩れる。
* 枠に収まらない説明文は、30分の予定は1行(時刻+予定名)に畳み、90分未満は読み上げ用にだけ残して
* 時刻つまみの欄で読ませる。行は両方の列で共有されるので、14:00 は左右で必ず同じ高さに来る。
*
* 2. 列ごとの <ol> は display: contents にして、li を親 grid に直接並べる
* 人ごとのリストという意味(読み上げ順)を残したまま、配置だけは共通の行に乗せられる。
*
* 3. 「今」の帯は測らずに置く
* 帯も grid-row で同じ行に置けば、座標を計算しなくてよい。
* 位置の移動は瞬時にし、key の付け替えで opacity だけを再生する(transform/opacity のみ)。
* 予定を bg-white にして行罫線を隠し、帯は予定より前面で mix-blend-multiply にすると文字を濁さない。
*
* 4. 狭幅では列を1本にして、人の切り替えを aria-pressed のボタン2つにする
* B の予定は col-start-2 @2xl:col-start-3。選ばれていない人の li は hidden @2xl:flex で消す。
*/
type Kind = "focus" | "talk" | "tidy" | "break";
type Entry = {
/** 9:00 からの分 */
start: number;
end: number;
title: string;
note: string;
kind: Kind;
};
type Person = {
id: "a" | "b";
role: string;
short: string;
profile: string;
entries: Entry[];
};
const DAY_START = 9 * 60;
const SLOT = 30;
const SLOTS = 20; // 9:00〜19:00
// 30分=1行の高さ。固定にしないと、説明文のある短い予定だけ行が伸びて「長さ=時間」が崩れる
const ROW = "2.75rem";
// 一言コメントを枠の中に出す最小の行数(90分以上)。それより短い予定は、時刻つまみの欄で読ませる
const NOTE_MIN_ROWS = 3;
const KINDS: Record<Kind, { label: string; swatch: string }> = {
focus: { label: "ひとりで集中", swatch: "bg-amber-600" },
talk: { label: "人と話す", swatch: "bg-amber-300" },
tidy: { label: "整える", swatch: "bg-gray-300" },
break: { label: "休憩", swatch: "bg-white border border-gray-300" },
};
const PEOPLE: Person[] = [
{
id: "a",
role: "Webディレクター",
short: "ディレクター",
profile: "在籍3年・週2日出社",
entries: [
{ start: 0, end: 30, title: "始業・メール整理", note: "今日決めることを3つだけ書き出してから始めます", kind: "tidy" },
{ start: 30, end: 60, title: "朝会", note: "15分で切り上げ、残りは相談の時間に回します", kind: "talk" },
{ start: 60, end: 180, title: "要件の整理", note: "先週のヒアリングを、画面の単位に割り直す時間", kind: "focus" },
{ start: 180, end: 240, title: "昼休憩", note: "近所を一周してから戻ります", kind: "break" },
{ start: 240, end: 300, title: "お客さまと定例", note: "決まったことは、その場で議事録に書いて共有", kind: "talk" },
{ start: 300, end: 390, title: "ワイヤーの読み合わせ", note: "実装担当と画面を見ながら、崩れ方まで決めます", kind: "talk" },
{ start: 390, end: 510, title: "進行表と見積の更新", note: "約束した日付を、根拠と一緒に書き直す", kind: "focus" },
{ start: 510, end: 540, title: "日報", note: "明日の自分への申し送りを書いて終わり", kind: "tidy" },
],
},
{
id: "b",
role: "フロントエンドエンジニア",
short: "エンジニア",
profile: "在籍2年・フルリモート",
entries: [
{ start: 30, end: 60, title: "朝会", note: "フレックスなので、ここから合流", kind: "talk" },
{ start: 60, end: 210, title: "実装", note: "午前は通知を切って、部品を1つ仕上げる", kind: "focus" },
{ start: 210, end: 270, title: "昼休憩", note: "", kind: "break" },
{ start: 270, end: 300, title: "コードレビュー", note: "指摘は短く、理由は長めに書きます", kind: "talk" },
{ start: 300, end: 390, title: "ワイヤーの読み合わせ", note: "作れる形かどうかを、ここで先に言っておく", kind: "talk" },
{ start: 390, end: 570, title: "実装と検証", note: "375pxと1280pxで崩れを見て、キーボードでも触る", kind: "focus" },
{ start: 570, end: 600, title: "日報", note: "詰まったところは、翌朝の朝会で聞く用に残す", kind: "tidy" },
],
},
];
function clock(minutesFromStart: number) {
const t = DAY_START + minutesFromStart;
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
}
function entryAt(person: Person, slot: number) {
const t = slot * SLOT;
return person.entries.find((e) => e.start <= t && t < e.end);
}
function hoursByKind(person: Person) {
const total: Record<Kind, number> = { focus: 0, talk: 0, tidy: 0, break: 0 };
for (const e of person.entries) total[e.kind] += (e.end - e.start) / 60;
return total;
}
export default function DayInTheLifeRecruit() {
const uid = useId();
const [slot, setSlot] = useState(4); // 11:00
const [shown, setShown] = useState<Person["id"]>("a");
const now = clock(slot * SLOT);
const doing = PEOPLE.map((p) => ({ person: p, entry: entryAt(p, slot) }));
const together =
doing[0].entry && doing[1].entry && doing[0].entry.title === doing[1].entry.title;
return (
<section
aria-labelledby={`${uid}-title`}
className="@container w-full max-w-4xl bg-white px-1 text-gray-900"
>
<header className="max-w-2xl">
<p className="text-[12px] font-semibold text-amber-700">
社員の1日<span className="mx-2 text-gray-300">/</span>
<span className="text-gray-500">ある水曜日</span>
</p>
<h2
id={`${uid}-title`}
className="mt-3 text-[clamp(1.5rem,3.6cqw,2.1rem)] leading-[1.35] font-bold tracking-tight"
>
ふたりの時間割を、
<br className="@md:hidden" />
同じ時計で見る
</h2>
<p className="mt-4 text-[13.5px] leading-[1.95] font-medium text-gray-600">
職種の違うふたりの1日を、同じ時刻の軸に並べました。予定の縦の長さはかかった時間に比例します。つまみを動かすと、その時刻にそれぞれが何をしているかが分かります(時刻と内容は架空のサンプルです)
</p>
</header>
{/* 時刻つまみ+その瞬間の読み上げ */}
<div className="mt-9 border-y border-gray-200 py-5 @2xl:grid @2xl:grid-cols-[1fr_1.2fr] @2xl:items-center @2xl:gap-x-10">
<div>
<label htmlFor={`${uid}-scrub`} className="flex items-baseline justify-between gap-4">
<span className="text-[12px] font-bold text-gray-500">時刻を動かす</span>
<span
className={`${montserrat.className} text-[1.65rem] leading-none font-bold text-gray-900 tabular-nums`}
>
{now}
</span>
</label>
<input
id={`${uid}-scrub`}
type="range"
min={0}
max={SLOTS - 1}
step={1}
value={slot}
aria-valuetext={now}
onChange={(e) => setSlot(Number(e.target.value))}
className="mt-3 block w-full cursor-pointer accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
/>
<div
aria-hidden="true"
className={`${montserrat.className} mt-1.5 flex justify-between text-[10.5px] font-medium text-gray-400 tabular-nums`}
>
<span>9:00</span>
<span>14:00</span>
<span>18:30</span>
</div>
</div>
<dl aria-live="polite" className="mt-5 flex flex-col gap-2.5 @2xl:mt-0">
{doing.map(({ person, entry }) => (
<div key={person.id} className="grid grid-cols-[5.5rem_1fr] items-baseline gap-x-3">
<dt className="text-[12px] font-semibold text-gray-500">{person.short}</dt>
<dd className="text-[14px] leading-[1.6] font-bold text-gray-900">
{entry ? (
<>
<span
aria-hidden="true"
className={`mr-2 inline-block h-2 w-2 align-[0.08em] ${KINDS[entry.kind].swatch}`}
/>
{entry.title}
{entry.note ? (
<span className="mt-0.5 block text-[12px] leading-[1.7] font-medium text-gray-600">
{entry.note}
</span>
) : null}
</>
) : (
<span className="font-medium text-gray-400">
{slot * SLOT < person.entries[0].start ? "始業前(フレックス)" : "退勤済み"}
</span>
)}
</dd>
</div>
))}
<p className="min-h-[1.5em] text-[12px] font-medium text-amber-700">
{together ? "ふたりとも同じ場にいます" : ""}
</p>
</dl>
</div>
{/* 狭幅: 人の切り替え(広幅では2列を並べるので不要) */}
<div role="group" aria-label="表示する人" className="mt-7 grid grid-cols-2 border border-gray-300 @2xl:hidden">
{PEOPLE.map((p) => (
<button
key={p.id}
type="button"
aria-pressed={shown === p.id}
onClick={() => setShown(p.id)}
className={`px-3 py-2.5 text-[13px] font-bold focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 ${
shown === p.id ? "bg-gray-900 text-white" : "bg-white text-gray-500"
}`}
>
{p.short}
</button>
))}
</div>
{/* 列見出し(広幅のみ) */}
<div className="mt-9 hidden grid-cols-[4.5rem_1fr_1fr] @2xl:grid">
<span aria-hidden="true" />
{PEOPLE.map((p) => (
<div key={p.id} className="border-b border-gray-900 pb-3 pl-4">
<p className="text-[15px] font-bold">{p.role}</p>
<p className="mt-0.5 text-[12px] text-gray-500">{p.profile}</p>
</div>
))}
</div>
<p className="mt-4 text-[12px] text-gray-500 @2xl:hidden">
{PEOPLE.find((p) => p.id === shown)?.role}・{PEOPLE.find((p) => p.id === shown)?.profile}
</p>
{/* 時間割本体: 30分=1行。両者の予定は同じ行を共有する */}
<div
className="relative mt-2 grid grid-cols-[3.25rem_1fr] @2xl:grid-cols-[4.5rem_1fr_1fr]"
style={{ gridTemplateRows: `repeat(${SLOTS}, ${ROW})` }}
>
{/* 行の罫線と時刻(正時だけ数字・右揃え) */}
{Array.from({ length: SLOTS }, (_, i) => {
const onHour = i % 2 === 0;
return (
<div
key={`rule-${i}`}
aria-hidden="true"
style={{ gridRow: `${i + 1} / span 1`, gridColumn: "1 / -1" }}
className={`pointer-events-none border-t ${onHour ? "border-gray-200" : "border-gray-100"}`}
/>
);
})}
{Array.from({ length: SLOTS }, (_, i) =>
i % 2 === 0 || i === slot ? (
<span
key={`hour-${i}`}
aria-hidden="true"
style={{ gridRow: `${i + 1} / span 1`, gridColumn: "1 / 2" }}
className={`${montserrat.className} relative z-30 pt-1.5 pr-3 text-right text-[11.5px] font-semibold tabular-nums @2xl:pr-4 ${
i === slot ? "text-amber-700" : "text-gray-400"
}`}
>
{clock(i * SLOT)}
</span>
) : null,
)}
{/* 「今」の帯: 測らずに同じ行へ置く。予定(白地)より前面に置き、乗算で文字を濁さない */}
<div
key={`now-${slot}`}
aria-hidden="true"
style={{ gridRow: `${slot + 1} / span 1`, gridColumn: "1 / -1" }}
className="dayline-fade pointer-events-none relative z-20 bg-amber-100/70 mix-blend-multiply [animation:dayline-in_260ms_ease-out_both]"
/>
{PEOPLE.map((p) => (
<ol key={p.id} aria-label={`${p.role}の1日`} className="contents">
{p.entries.map((e) => {
const active = e.start <= slot * SLOT && slot * SLOT < e.end;
const isBreak = e.kind === "break";
const rows = (e.end - e.start) / SLOT;
const oneRow = rows === 1;
return (
<li
key={e.start}
style={{ gridRow: `${e.start / SLOT + 1} / ${e.end / SLOT + 1}` }}
className={`relative z-10 ml-2 overflow-hidden border-l bg-white shadow-[inset_0_1px_0_#e5e7eb] pr-2 pl-3.5 @2xl:ml-4 @2xl:flex ${
oneRow ? "flex-row items-baseline gap-x-2 py-1.5" : "flex-col py-2"
} ${
p.id === "b" ? "col-start-2 @2xl:col-start-3" : "col-start-2"
} ${shown === p.id ? "flex" : "hidden"} ${
isBreak ? "border-dashed border-gray-300" : "border-gray-300"
}`}
>
<span
aria-hidden="true"
className={`absolute inset-y-0 -left-px block w-[3px] origin-top bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
active ? "scale-y-100" : "scale-y-0"
}`}
/>
<span
className={`${montserrat.className} shrink-0 text-[11px] font-medium text-gray-500 tabular-nums`}
>
{clock(e.start)}–{clock(e.end)}
</span>
<span
className={`flex min-w-0 items-center gap-2 text-[14px] leading-[1.5] font-bold ${
oneRow ? "" : "mt-0.5"
} ${isBreak ? "text-gray-400" : "text-gray-900"}`}
>
<span aria-hidden="true" className={`block h-2 w-2 shrink-0 ${KINDS[e.kind].swatch}`} />
<span className="truncate">{e.title}</span>
</span>
{e.note ? (
// 枠に収まらない短い予定は読み上げ用にだけ残し、目では時刻つまみの欄で読ませる
<span
className={
rows >= NOTE_MIN_ROWS
? "mt-1 line-clamp-2 text-[12.5px] leading-[1.75] text-gray-600"
: "sr-only"
}
>
{e.note}
</span>
) : null}
</li>
);
})}
</ol>
))}
</div>
{/* 1日の内訳(同じ9時間台でも中身の配分が違うことを数字で) */}
<div className="mt-10 border-t border-gray-900 pt-6">
<h3 className="text-[15px] font-bold">1日の内訳</h3>
<ul role="list" className="mt-4 flex flex-wrap gap-x-5 gap-y-2">
{(Object.keys(KINDS) as Kind[]).map((k) => (
<li key={k} className="flex items-center gap-2 text-[12px] text-gray-600">
<span aria-hidden="true" className={`block h-2.5 w-2.5 ${KINDS[k].swatch}`} />
{KINDS[k].label}
</li>
))}
</ul>
<div className="mt-5 flex flex-col divide-y divide-gray-200 border-y border-gray-200">
{PEOPLE.map((p) => {
const hours = hoursByKind(p);
const total = Object.values(hours).reduce((a, b) => a + b, 0);
return (
<div key={p.id} className="grid gap-y-3 py-4 @2xl:grid-cols-[11rem_1fr] @2xl:items-center @2xl:gap-x-6">
<p className="text-[13px] font-bold">
{p.short}
<span className={`${montserrat.className} ml-2 text-[12px] font-medium text-gray-500 tabular-nums`}>
{total.toFixed(1)}h
</span>
</p>
<div>
<div aria-hidden="true" className="flex h-2.5 gap-px">
{(Object.keys(KINDS) as Kind[]).map((k) =>
hours[k] > 0 ? (
<span key={k} style={{ flexGrow: hours[k] }} className={`block basis-0 ${KINDS[k].swatch}`} />
) : null,
)}
</div>
<dl className="mt-2.5 grid grid-cols-2 gap-x-4 gap-y-1 @md:grid-cols-4">
{(Object.keys(KINDS) as Kind[]).map((k) => (
<div key={k} className="flex items-baseline justify-between gap-2">
<dt className="text-[11.5px] text-gray-500">{KINDS[k].label}</dt>
<dd className={`${montserrat.className} text-[12.5px] font-semibold text-gray-900 tabular-nums`}>
{hours[k].toFixed(1)}h
</dd>
</div>
))}
</dl>
</div>
</div>
);
})}
</div>
</div>
<div className="mt-8 flex flex-col gap-y-3.5 @md:flex-row @md:items-center @md:gap-x-6">
<a
href="#"
className="inline-flex shrink-0 items-center justify-center bg-amber-600 px-8 py-4 text-[14px] leading-none font-bold text-white transition-colors duration-200 hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
募集中の職種を見る
</a>
<p className="text-[12.5px] leading-[1.85] text-gray-500">
出社する曜日と始業の時刻は、チームと相談して自分で決めています
</p>
</div>
{/* 帯の再生(opacity のみ)。reduced-motion は同じ <style> 内で打ち消す */}
<style>{`
@keyframes dayline-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.dayline-fade { animation: none !important; }
}
`}</style>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/day-in-the-life-recruit.json