Seminar Schedule — Date-Block List
An event schedule list anchored by an oversized tabular month/day block on the left edge. Each row carries seat inventory: the capacity state (open / few left / full / closed) drives a tick-mark seat gauge, the application deadline, and whether the row is actionable at all. Full and past sessions are marked with a tilted stamp instead of a card, and past sessions are toggled with a switch. No rounded corners, no uppercase kicker, no arrow links — months are grouped with hairline rules and whitespace alone. Image-free, dependency-free.
- Added:
- 2026-09-02
- Dependencies:
- None
- tags
- #block #event #seminar #schedule #list #date #availability #no-image
Preview
開催予定のセミナー
制作の現場で実際に聞かれることを題材にした少人数の勉強会です。会場開催は席数が限られるため、残席と締切をご確認のうえお申し込みください。掲載内容はすべて架空のサンプルです。
BLK-49
全4回 受付中
受付中の4件を表示しています
09月20262件
- 金曜オンライン19:00–20:30オンライン開催(ウェビナー)
問い合わせが止まるサイトの直し方
アクセスはあるのに反応がない。原因を導線・文言・フォームの3層に分けて切り分ける手順を、実際の改修前後の画面で追います。
受付中残り12席 /40席
申込締切 9月9日
- 木曜会場14:00–16:30本社セミナールーム(定員24名)
自分たちで更新できるサイトの決め方
公開後にどこを触れるようにするかを、要件定義の段階で線引きするためのワークショップ。持ち帰り用のチェックシート付き。
残りわずか残り3席 /24席
申込締切 9月19日
10月20262件
- 木曜オンライン19:00–20:30オンライン開催(ウェビナー)
見積書の行を読み解く90分
制作会社の見積が比べられないのは、粒度が揃っていないからです。行の意味と、聞き返すべき項目を具体的に整理します。
満席
申込締切 10月6日
満席2026.10.08キャンセル待ち(2026-10-08 見積書の行を読み解く90分) - 木曜会場13:30–17:00本社セミナールーム(定員18名)
撮影のない会社案内ページをつくる半日
写真素材が用意できない状態から、文字組みと余白だけで紙面を立てる進め方。その場で1ページ分を組み上げます。
受付中残り9席 /18席
申込締切 10月16日
日程が合わない場合は、次回開催のご案内をお送りします。過去の開催内容は上のスイッチでご確認いただけます。
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* セミナー・イベント一覧(開催日ブロック型) / Seminar Schedule — Date-Block List
*
* 差別化: 同じ棚の既存一覧(罫線ニュースリスト・主従+タブ・読了時間ソート)はどれも
* 「全行が等価に開ける読み物」で、行の違いは分類か長さだけだった。本ブロックは1行が席という
* 在庫を持ち、残席の状態(受付中/残りわずか/満席/受付終了)が残席ゲージ・締切表示・
* 操作の可否そのものを切り替える「申込台帳」として組んでいる。日付は識別子ではなく予定を
* 押さえる対象なので、左端に月/日を特大のタブラー数字で積んで行の起点にした。
* 掲載している日付・題目・講師名はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
*
* 飽和idiomを3つ外している: 英字キッカー(uppercase+tracking)/角丸(rounded-* を一切使わない・
* 判子と席目盛りの直角が効く)/末尾の「→」リンク(行のCTAは動詞のボタンで、矢印を持たない)。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **在庫を持つ一覧は「状態」を1つの型にまとめる**: 受付中/残りわずか/満席/終了を union で持ち、
* ラベル・色・操作可否・判子の有無をその1箇所から導く。行のJSXに条件分岐を散らさないので、
* 状態が増えても崩れない(見た目の差はテーブル1枚で説明できる状態にしておく)。
* - **残席ゲージは目盛りの本数で見せる**: 割合バー1本ではなく w-[3px] の縦目盛りを席数分だけ並べ、
* 埋まった分を amber、空きを gray-200 にする。「あと3本」が数えられるので、%より切迫が伝わる。
* 出現時は origin-bottom の scaleY(0)→1 を index*28ms でずらす(高さは変えずリフローゼロ)。
* - **判子(スタンプ)は rotate+scale を inline style の transform で1つにまとめる**: Tailwind v4 の
* rotate/scale ユーティリティは transform ではなく個別プロパティ(rotate:/scale:)に出るので、
* 自前の transform を持つ要素に足しても合成されず、狙いの姿勢にならない。押印の
* 「大きく降りてくる」演出は transform: rotate(-8deg) scale(1.6) → scale(1) の1本で書く。
* (transition-transform 自体は v4 では transform/translate/scale/rotate すべてを対象にする)
* - **終了回は非表示が既定・トグルで戻す**: 絞り込み(カテゴリ)と違い時間軸の開閉なので、
* role="switch" + aria-checked で「入/切」として持たせ、件数変化は sr-only の aria-live で伝える。
* - **日付ブロックはカードにしない**: 面(bg/枠)を与えず、左端の w-px hairline と amber の
* 3px マーカーだけで列を作る。行の状態が変わってもマーカーの色が変わるだけで面が動かない。
*/
/** 席の在庫状態。ラベル・色・操作・判子はすべてここから導く */
type Status = "open" | "few" | "full" | "closed";
type Session = {
id: string;
/** 開催日(time要素の datetime にもそのまま使う) */
date: string;
/** 表示用の曜日(datetime は date 側が持つので装飾扱い) */
weekday: string;
start: string;
end: string;
format: "会場" | "オンライン";
place: string;
title: string;
lead: string;
/** 定員と残席。ゲージの目盛り本数は capacity をそのまま使う */
capacity: number;
left: number;
/** 申込締切(受付終了回は表示しない) */
deadline?: string;
};
const STATUS_STYLE: Record<
Status,
{ label: string; text: string; marker: string; action: string | null }
> = {
open: { label: "受付中", text: "text-amber-700", marker: "bg-amber-600", action: "申し込む" },
few: { label: "残りわずか", text: "text-amber-700", marker: "bg-amber-600", action: "申し込む" },
full: { label: "満席", text: "text-gray-500", marker: "bg-gray-400", action: "キャンセル待ち" },
closed: { label: "受付終了", text: "text-gray-400", marker: "bg-gray-200", action: null },
};
function statusOf(s: Session, ended: boolean): Status {
if (ended) return "closed";
if (s.left === 0) return "full";
return s.left <= 3 ? "few" : "open";
}
const upcoming: Session[] = [
{
id: "s-09-11",
date: "2026-09-11",
weekday: "金",
start: "19:00",
end: "20:30",
format: "オンライン",
place: "オンライン開催(ウェビナー)",
title: "問い合わせが止まるサイトの直し方",
lead: "アクセスはあるのに反応がない。原因を導線・文言・フォームの3層に分けて切り分ける手順を、実際の改修前後の画面で追います。",
capacity: 40,
left: 12,
deadline: "9月9日",
},
{
id: "s-09-24",
date: "2026-09-24",
weekday: "木",
start: "14:00",
end: "16:30",
format: "会場",
place: "本社セミナールーム(定員24名)",
title: "自分たちで更新できるサイトの決め方",
lead: "公開後にどこを触れるようにするかを、要件定義の段階で線引きするためのワークショップ。持ち帰り用のチェックシート付き。",
capacity: 24,
left: 3,
deadline: "9月19日",
},
{
id: "s-10-08",
date: "2026-10-08",
weekday: "木",
start: "19:00",
end: "20:30",
format: "オンライン",
place: "オンライン開催(ウェビナー)",
title: "見積書の行を読み解く90分",
lead: "制作会社の見積が比べられないのは、粒度が揃っていないからです。行の意味と、聞き返すべき項目を具体的に整理します。",
capacity: 40,
left: 0,
deadline: "10月6日",
},
{
id: "s-10-22",
date: "2026-10-22",
weekday: "木",
start: "13:30",
end: "17:00",
format: "会場",
place: "本社セミナールーム(定員18名)",
title: "撮影のない会社案内ページをつくる半日",
lead: "写真素材が用意できない状態から、文字組みと余白だけで紙面を立てる進め方。その場で1ページ分を組み上げます。",
capacity: 18,
left: 9,
deadline: "10月16日",
},
];
const past: Session[] = [
{
id: "s-08-21",
date: "2026-08-21",
weekday: "金",
start: "19:00",
end: "20:30",
format: "オンライン",
place: "オンライン開催(ウェビナー)",
title: "運用でサイトが散らかっていく理由",
lead: "更新を重ねるほど読みにくくなる原因を、ページの増やし方と見出しの付け方から点検しました。",
capacity: 40,
left: 0,
},
{
id: "s-07-30",
date: "2026-07-30",
weekday: "木",
start: "14:00",
end: "16:00",
format: "会場",
place: "本社セミナールーム(定員24名)",
title: "はじめての採用サイト設計",
lead: "求人媒体との役割分担を決めてから、載せる情報の順番を組み立てるまでを扱いました。",
capacity: 24,
left: 6,
},
];
/** 月ごとにまとめる(順序は配列のまま=時系列) */
function groupByMonth(list: Session[]) {
const groups: { key: string; year: string; month: string; items: Session[] }[] = [];
for (const s of list) {
const [year, month] = s.date.split("-");
const key = `${year}-${month}`;
const last = groups.at(-1);
if (last?.key === key) last.items.push(s);
else groups.push({ key, year, month, items: [s] });
}
return groups;
}
export default function SeminarScheduleList() {
const rootRef = useRef<HTMLElement | null>(null);
const [active, setActive] = useState(false);
const [showPast, setShowPast] = useState(false);
const switchId = useId();
// セクションが視野に入ったら1度だけ全体を立ち上げる(行ごとの遅延はCSS側で付ける)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const reduced =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduced || typeof IntersectionObserver === "undefined") {
// 「止める」のではなく最初から完成状態にする(setStateは次フレームへ逃がす)
const id = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(id);
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setActive(true);
io.disconnect();
}
},
{ threshold: 0.1 },
);
io.observe(el);
return () => io.disconnect();
}, []);
const groups = useMemo(
() => [
...groupByMonth(upcoming).map((g) => ({ ...g, ended: false })),
...(showPast ? [{ key: "past", year: "", month: "", items: past, ended: true }] : []),
],
[showPast],
);
const reveal = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
active
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`;
// 行の通し番号(グループをまたいでスタッガーを連続させる)
let rowIndex = -1;
return (
<section
ref={rootRef}
aria-labelledby="seminar-schedule-heading"
className="w-full max-w-5xl bg-white px-1"
>
<header className="flex flex-col gap-x-10 gap-y-5 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<h2
id="seminar-schedule-heading"
className={`text-[clamp(1.55rem,3.2vw,2.15rem)] leading-[1.35] font-bold tracking-tight text-gray-900 ${reveal}`}
>
開催予定のセミナー
</h2>
<p
className={`mt-3 max-w-xl text-[13.5px] leading-[1.95] font-medium text-gray-600 ${reveal}`}
style={{ transitionDelay: "90ms" }}
>
{/* 和文は改行で分けると「、 残」のように空きが出るため1行で書く */}
制作の現場で実際に聞かれることを題材にした少人数の勉強会です。会場開催は席数が限られるため、残席と締切をご確認のうえお申し込みください。掲載内容はすべて架空のサンプルです。
</p>
</div>
<div className={`shrink-0 md:text-right ${reveal}`} style={{ transitionDelay: "160ms" }}>
<p className={`${montserrat.className} text-[11px] font-semibold text-gray-400`}>
BLK-49
</p>
<p className="mt-1 text-[12.5px] font-semibold text-gray-500">
全
<span className={`${montserrat.className} mx-0.5 text-gray-900 tabular-nums`}>
{upcoming.length}
</span>
回 受付中
</p>
</div>
</header>
{/* 終了した回の開閉。絞り込みではなく時間軸の入/切なので switch で持つ */}
<div
className={`mt-6 flex items-center justify-between gap-4 ${reveal}`}
style={{ transitionDelay: "220ms" }}
>
<label
htmlFor={switchId}
className="text-[12.5px] font-semibold tracking-[0.04em] text-gray-600"
>
終了した回も表示
</label>
<button
type="button"
id={switchId}
role="switch"
aria-checked={showPast}
onClick={() => setShowPast((v) => !v)}
className={`relative inline-flex h-5 w-11 shrink-0 items-center border transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none ${
showPast ? "border-amber-600 bg-amber-50" : "border-gray-300 bg-white"
}`}
>
<span
aria-hidden="true"
className={`ml-px h-[15px] w-[19px] transition-transform duration-200 ease-out motion-reduce:transition-none ${
showPast ? "translate-x-[21px] bg-amber-600" : "translate-x-0 bg-gray-300"
}`}
/>
</button>
</div>
<p aria-live="polite" className="sr-only">
{showPast
? `終了した回を含む全${upcoming.length + past.length}件を表示しています`
: `受付中の${upcoming.length}件を表示しています`}
</p>
<div className="mt-2">
{groups.map((group, gi) => (
<section key={group.key} aria-label={group.ended ? "終了した回" : `${group.month}月の開催`}>
{/* 月見出し: 面を持たず、hairline と余白だけで段を作る */}
<h3 className="flex items-baseline gap-3 border-b border-gray-200 pt-8 pb-2">
{group.ended ? (
<span className="text-[13px] font-bold tracking-[0.06em] text-gray-400">
終了した回
</span>
) : (
<>
<span
className={`${montserrat.className} text-[22px] leading-none font-bold text-gray-900 tabular-nums`}
>
{group.month}
</span>
<span className="text-[12.5px] font-bold text-gray-900">月</span>
<span
className={`${montserrat.className} text-[11px] font-semibold text-gray-400 tabular-nums`}
>
{group.year}
</span>
</>
)}
<span
className={`${montserrat.className} ml-auto text-[11px] font-semibold text-gray-400 tabular-nums`}
>
{group.items.length}
<span className="ml-0.5 font-sans text-gray-400">件</span>
</span>
</h3>
<ul>
{group.items.map((s) => {
rowIndex += 1;
const status = statusOf(s, group.ended);
const style = STATUS_STYLE[status];
const dimmed = status === "closed";
const [, month, day] = s.date.split("-");
const filled = s.capacity - s.left;
const delay = 300 + rowIndex * 90;
return (
<li
key={s.id}
className={`border-b border-gray-200 ${reveal}`}
style={{ transitionDelay: active ? `${delay}ms` : "0ms" }}
>
<div className="grid grid-cols-[auto_1fr] gap-x-4 py-6 sm:gap-x-7 md:grid-cols-[auto_1fr_auto] md:gap-x-8">
{/* 日付ブロック: 月/日/曜日を積む。カードにせず左端のマーカーだけで列を作る */}
<div className="relative flex flex-col items-center pl-3 sm:pl-4">
<span
aria-hidden="true"
className={`absolute top-1 bottom-1 left-0 w-[3px] ${style.marker}`}
/>
<time
dateTime={s.date}
className={`flex flex-col items-center ${dimmed ? "text-gray-400" : "text-gray-900"}`}
>
<span
className={`${montserrat.className} text-[13px] leading-none font-semibold tabular-nums ${
dimmed ? "text-gray-400" : "text-amber-700"
}`}
>
{month}
<span className="ml-px font-sans text-[10px]">月</span>
</span>
<span
className={`${montserrat.className} mt-1 text-[clamp(2.4rem,7vw,3.1rem)] leading-[0.86] font-bold tracking-tight tabular-nums`}
>
{day}
</span>
</time>
<span
className={`mt-1.5 text-[11px] font-semibold ${dimmed ? "text-gray-300" : "text-gray-500"}`}
>
{s.weekday}曜
</span>
</div>
{/* 本文 */}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span
className={`inline-flex h-[22px] items-center border px-2 text-[11px] font-bold tracking-[0.04em] ${
dimmed
? "border-gray-200 text-gray-400"
: s.format === "オンライン"
? "border-amber-600 text-amber-700"
: "border-gray-900 text-gray-900"
}`}
>
{s.format}
</span>
<span
className={`${montserrat.className} text-[12.5px] font-semibold tabular-nums ${
dimmed ? "text-gray-400" : "text-gray-700"
}`}
>
{s.start}–{s.end}
</span>
<span
aria-hidden="true"
className="hidden h-3 w-px bg-gray-200 sm:block"
/>
<span
className={`text-[12px] font-medium ${dimmed ? "text-gray-400" : "text-gray-500"}`}
>
{s.place}
</span>
</div>
<h4
className={`mt-2.5 text-[clamp(1rem,1.9vw,1.2rem)] leading-[1.5] font-bold tracking-tight ${
dimmed ? "text-gray-400" : "text-gray-900"
}`}
>
{s.title}
</h4>
<p
className={`mt-2 max-w-2xl text-[13px] leading-[1.9] font-medium ${
dimmed ? "text-gray-400" : "text-gray-600"
}`}
>
{s.lead}
</p>
{/* 残席ゲージ: 席数ぶんの目盛りを並べ、埋まった分だけ塗る */}
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
{/* 目盛りの間隔は狭幅で詰める(40席でも1行に収める) */}
<div className="flex items-end gap-px sm:gap-[2px]" aria-hidden="true">
{Array.from({ length: s.capacity }, (_, i) => (
<span
key={i}
className={`h-3 w-[2px] origin-bottom transition-transform sm:w-[3px] duration-500 ease-out motion-reduce:transition-none ${
dimmed
? "bg-gray-200"
: i < filled
? "bg-amber-600"
: "bg-gray-200"
}`}
style={{
// 目盛りごとに遅延を変えるので transform も style 側にまとめる
// (Tailwind の scale-* は scale プロパティに出るため自前の transform と混ぜない)
transform: `scaleY(${active ? 1 : 0})`,
transitionDelay: active ? `${delay + 120 + i * 28}ms` : "0ms",
}}
/>
))}
</div>
<p
className={`text-[12px] font-bold tracking-[0.04em] ${style.text}`}
>
{style.label}
{!dimmed && s.left > 0 && (
<span className="ml-2 font-semibold text-gray-500">
残り
<span
className={`${montserrat.className} mx-0.5 text-gray-900 tabular-nums`}
>
{s.left}
</span>
席 /
<span
className={`${montserrat.className} mx-0.5 tabular-nums`}
>
{s.capacity}
</span>
席
</span>
)}
</p>
{s.deadline && !dimmed && (
<p className="text-[12px] font-semibold text-gray-500">
申込締切 <span className="text-gray-900">{s.deadline}</span>
</p>
)}
</div>
</div>
{/* 操作列 / 判子。満席・終了は押せる先がないので判子に置き換える */}
<div className="col-span-2 mt-4 flex items-center gap-4 md:col-span-1 md:mt-0 md:w-[148px] md:flex-col md:items-end md:justify-center md:gap-3">
{dimmed || status === "full" ? (
<span
className={`inline-flex h-[54px] w-[76px] flex-col items-center justify-center border-2 text-[13px] leading-none font-bold tracking-[0.08em] transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none ${
status === "full"
? "border-amber-600/70 text-amber-700"
: "border-gray-300 text-gray-400"
} ${active ? "opacity-100" : "opacity-0 motion-reduce:opacity-100"}`}
style={{
transform: `rotate(-8deg) scale(${active ? 1 : 1.6})`,
transitionDelay: active ? `${delay + 260}ms` : "0ms",
}}
>
<span className="border-b border-current pb-1">
{status === "full" ? "満席" : "終了"}
</span>
<span
className={`${montserrat.className} mt-1 text-[9px] font-semibold tracking-[0.12em] tabular-nums`}
>
{s.date.replace(/-/g, ".")}
</span>
</span>
) : null}
{style.action && !dimmed && (
<a
href="#"
className={`inline-flex h-10 items-center justify-center px-5 text-[13px] font-bold tracking-[0.04em] whitespace-nowrap transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none md:w-full ${
status === "full"
? "border border-gray-300 text-gray-700 hover:border-gray-900 hover:text-gray-900"
: "bg-amber-600 text-white hover:bg-amber-700"
}`}
>
{style.action}
<span className="sr-only">
({s.date} {s.title})
</span>
</a>
)}
</div>
</div>
</li>
);
})}
</ul>
{gi === groups.length - 1 && !showPast && (
<p className="pt-5 text-[12.5px] leading-[1.9] font-medium text-gray-500">
日程が合わない場合は、次回開催のご案内をお送りします。過去の開催内容は上のスイッチでご確認いただけます。
</p>
)}
</section>
))}
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/seminar-schedule-list.json