BLK-83Blocks

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日出社

  1. 9:00–9:30始業・メール整理今日決めることを3つだけ書き出してから始めます
  2. 9:30–10:00朝会15分で切り上げ、残りは相談の時間に回します
  3. 10:00–12:00要件の整理先週のヒアリングを、画面の単位に割り直す時間
  4. 12:00–13:00昼休憩近所を一周してから戻ります
  5. 13:00–14:00お客さまと定例決まったことは、その場で議事録に書いて共有
  6. 14:00–15:30ワイヤーの読み合わせ実装担当と画面を見ながら、崩れ方まで決めます
  7. 15:30–17:30進行表と見積の更新約束した日付を、根拠と一緒に書き直す
  8. 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