BLK-50Blocks
拠点・店舗一覧(罫線グリッド)
複数拠点を1枚の台帳として横断比較させる一覧ブロック。拠点名・所在地・電話・営業時間を同じ列に揃え、角丸も影も面の塗りも使わず、横罫と縦罫の直交だけで律動を作る。現在時刻から各拠点の営業状況(営業中/まもなく終了/本日休業)を算出して表示し、エリア絞り込みと「営業中のみ」の2操作で候補を絞れる。電話は tel: リンク+タブラー数字、地図は細字のテキストリンクに留めた。地図・写真を持たないため画像素材ゼロで成立する。
- 追加日:
- 2026-09-03
- 依存:
- なし
- tags
- #locations #directory #hairline #grid #hours #tel #filter #tabular-nums #no-image
プレビュー
近い拠点と、いま開いている拠点
拠点名・所在地・電話・営業時間を同じ列に揃えました。現在時刻から営業状況を判定するので、電話をかける前に「いま開いているか」が分かります。
エリア
現在--:--
東日本
2拠点けやき通り本社
本社
営業時間
平日 10:00–19:00土 10:00–15:00
—
土曜は前日までのご予約制
みなと支社
支社
営業時間
平日 10:00–18:30
—
駐車場はありません
中部
2拠点名古屋支社
支社
営業時間
平日 9:30–18:30土 10:00–16:00
—
東海3県の制作案件の窓口
金沢ショールーム
ショールーム
営業時間
11:00–19:00水曜定休
—
事例サンプルを常設・予約不要
西日本
2拠点大阪支社
支社
営業時間
平日 9:30–19:00土 10:00–15:00
—
近畿・中国地方の保守運用窓口
福岡サテライト
サテライト
営業時間
平日 10:00–18:00
—
九州全域へ出張対応
営業状況はご覧の端末の時刻から算出した目安です。祝日・年末年始は全拠点休業となります。
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 拠点・店舗一覧(罫線グリッド・営業状況を実時刻から判定)
* 既存の会社概要系は「1拠点を深掘る」構造(定義リスト+地図/手段別の道順)が既出のため、
* 本ブロックは地図を持たず、N拠点を同じ列に揃えて横断比較する台帳型にした。
* 情報密度は行×属性の格子で稼ぎ、操作はエリア絞り込みと「いま営業中だけ」の2つに絞っている。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 脱カードの徹底: 角丸・影・面の塗りを一切使わず、罫線だけで律動を作る。横罫(レコードの区切り)と
* 縦罫(属性の列)を直交させると、箱を1つも置かずに「表」の読み味が出る。縦罫は行の padding ごと
* 貫かせたいので、行の grid には items-start を書かない(既定の stretch のままにする)。
* - 出現演出を「文字を動かす」から「罫線を引く」へ置き換えられる。各行の上罫を絶対配置の h-px にして
* origin-left の scale-x 0→1 を index で遅らせると、台帳が上から引かれていくリズムになる。
* transform だけなので motion-reduce は scale-x-100 へ倒すだけで済む。
* - 現在時刻から状態を出す表は、SSRとクライアントで結果が変わるので必ずマウント後に評価する
* (初期値 null → useEffect で now を入れる)。null の間は「—」を出しておけば hydration が割れない。
* 1分ごとの再評価は setInterval ではなく「次の分境界までの setTimeout」を張り直すと秒がずれない。
* - 電話・時刻・件数は tabular-nums を付けて桁を揃える。等幅にしないと行をまたいだ縦の目線が崩れ、
* 罫線で作った格子の効果がそこだけ消える。
* - 状態インジケータは円ではなく 6px の正方形にした。角丸を使わない面では、丸が1つあるだけで浮く。
*/
type Slot = { open: number; close: number } | null;
type Branch = {
id: string;
area: string;
name: string;
kind: string;
zip: string;
/** 町域まで(和文なのでここは途中で折り返してよい) */
address: string;
/** 番地。"0-0-" と "0" に割れるのを防ぐため単独で nowrap にする */
lot: string;
/** 建物名・階(「栄セントラル/ビル」と割れるのを防ぐため nowrap で1語として扱う) */
building: string;
/** 表示用(tel: は数字だけに正規化して渡す) */
tel: string;
/** 日曜=0 の週間営業時間。null は休業 */
week: Slot[];
/** 営業時間の要約。区切りごとに配列で持ち、各要素を nowrap にして「土」だけ行末に残さない */
hours: string[];
/** 拠点ごとの補足(1行) */
note: string;
};
/** "10:00" → 600 */
const hm = (s: string) => {
const [h, m] = s.split(":").map(Number);
return h * 60 + m;
};
const weekday = (
open: string,
close: string,
sat?: [string, string],
): Slot[] => [
null,
{ open: hm(open), close: hm(close) },
{ open: hm(open), close: hm(close) },
{ open: hm(open), close: hm(close) },
{ open: hm(open), close: hm(close) },
{ open: hm(open), close: hm(close) },
sat ? { open: hm(sat[0]), close: hm(sat[1]) } : null,
];
/** 定休日を1日だけ持つ通し営業(ショールーム型)。closedDay は 0=日 */
const everyday = (open: string, close: string, closedDay: number): Slot[] =>
Array.from({ length: 7 }, (_, d) =>
d === closedDay ? null : { open: hm(open), close: hm(close) },
);
const areas = ["東日本", "中部", "西日本"] as const;
const branches: Branch[] = [
{
id: "keyaki",
area: "東日本",
name: "けやき通り本社",
kind: "本社",
zip: "150-0001",
address: "東京都渋谷区神宮前",
lot: "0-0-0",
building: "First CHビル 5F",
tel: "03-0000-0000",
week: weekday("10:00", "19:00", ["10:00", "15:00"]),
hours: ["平日 10:00–19:00", "土 10:00–15:00"],
note: "土曜は前日までのご予約制",
},
{
id: "minato",
area: "東日本",
name: "みなと支社",
kind: "支社",
zip: "231-0000",
address: "神奈川県横浜市中区花咲町",
lot: "0-0-0",
building: "みなとオフィス 3F",
tel: "045-000-0000",
week: weekday("10:00", "18:30"),
hours: ["平日 10:00–18:30"],
note: "駐車場はありません",
},
{
id: "nagoya",
area: "中部",
name: "名古屋支社",
kind: "支社",
zip: "460-0000",
address: "愛知県名古屋市中区栄",
lot: "0-0-0",
building: "栄セントラルビル 11F",
tel: "052-000-0000",
week: weekday("09:30", "18:30", ["10:00", "16:00"]),
hours: ["平日 9:30–18:30", "土 10:00–16:00"],
note: "東海3県の制作案件の窓口",
},
{
id: "kanazawa",
area: "中部",
name: "金沢ショールーム",
kind: "ショールーム",
zip: "920-0000",
address: "石川県金沢市広坂",
lot: "0-0-0",
building: "広坂ハウス 1F",
tel: "076-000-0000",
week: everyday("11:00", "19:00", 3),
hours: ["11:00–19:00", "水曜定休"],
note: "事例サンプルを常設・予約不要",
},
{
id: "osaka",
area: "西日本",
name: "大阪支社",
kind: "支社",
zip: "530-0000",
address: "大阪府大阪市北区堂島",
lot: "0-0-0",
building: "堂島フロントビル 14F",
tel: "06-0000-0000",
week: weekday("09:30", "19:00", ["10:00", "15:00"]),
hours: ["平日 9:30–19:00", "土 10:00–15:00"],
note: "近畿・中国地方の保守運用窓口",
},
{
id: "fukuoka",
area: "西日本",
name: "福岡サテライト",
kind: "サテライト",
zip: "810-0000",
address: "福岡県福岡市中央区大名",
lot: "0-0-0",
building: "大名テラス 4F",
tel: "092-000-0000",
week: weekday("10:00", "18:00"),
hours: ["平日 10:00–18:00"],
note: "九州全域へ出張対応",
},
];
/* ------------------------------------------------------------ 営業状況 */
type StatusKind = "open" | "soon" | "before" | "after" | "closed";
type Status = { kind: StatusKind; label: string };
const pad = (n: number) => String(n).padStart(2, "0");
const clock = (mins: number) =>
`${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`;
/**
* now が null(=マウント前)のときは判定しない。
* SSR とクライアントで結果が変わる値を初期描画に混ぜると hydration が割れる。
*/
function statusOf(branch: Branch, now: Date | null): Status | null {
if (!now) return null;
const slot = branch.week[now.getDay()];
if (!slot) return { kind: "closed", label: "本日休業" };
const mins = now.getHours() * 60 + now.getMinutes();
if (mins < slot.open)
return { kind: "before", label: `${clock(slot.open)} から` };
if (mins >= slot.close) return { kind: "after", label: "本日終了" };
if (slot.close - mins <= 60) return { kind: "soon", label: "まもなく終了" };
return { kind: "open", label: "営業中" };
}
const statusStyle: Record<StatusKind, { mark: string; text: string }> = {
open: { mark: "bg-amber-600", text: "text-amber-700" },
soon: { mark: "bg-amber-400", text: "text-amber-700" },
before: { mark: "bg-gray-300", text: "text-gray-500" },
after: { mark: "bg-gray-300", text: "text-gray-500" },
closed: { mark: "bg-gray-200", text: "text-gray-400" },
};
/* ---------------------------------------------------------------- 本体 */
/** 4カラムの格子。列幅は minmax(0,…) にしないと長い住所で行が横へあふれる */
const GRID =
"@2xl:grid @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.45fr)_minmax(0,1fr)_minmax(0,1.2fr)]";
/** 縦罫を行の上下いっぱいまで通したいので、行ではなくセル側に padding を持たせる */
const PAD_X = "@2xl:px-4 @4xl:px-5 @2xl:first:pl-0 @2xl:last:pr-0";
const CELL = `${PAD_X} @2xl:py-5`;
const columnLabels = ["拠点", "所在地", "電話", "営業時間"];
export default function OfficeLocationsLedger() {
const rootRef = useRef<HTMLElement>(null);
const [active, setActive] = useState(false);
const [now, setNow] = useState<Date | null>(null);
const [area, setArea] = useState<string>("すべて");
const [onlyOpen, setOnlyOpen] = useState(false);
// 画面内に入ったら罫線を引き始める(IO 非対応環境は即時表示)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
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();
}, []);
// 現在時刻はマウント後にだけ入れる。次の「分」の境界で張り直すと表示がずれない
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const d = new Date();
setNow(d);
timer = setTimeout(tick, (60 - d.getSeconds()) * 1000 + 50);
};
tick();
return () => clearTimeout(timer);
}, []);
const rows = useMemo(
() => branches.map((b) => ({ branch: b, status: statusOf(b, now) })),
[now],
);
const visible = rows.filter(
(r) =>
(area === "すべて" || r.branch.area === area) &&
(!onlyOpen || r.status?.kind === "open" || r.status?.kind === "soon"),
);
const openCount = rows.filter(
(r) => r.status?.kind === "open" || r.status?.kind === "soon",
).length;
const groups = areas
.map((a) => ({
area: a,
items: visible.filter((r) => r.branch.area === a),
}))
.filter((g) => g.items.length > 0);
/**
* 罫線を引く順番(表示はしない)。描画中にカウンタを進めると eslint の
* react-hooks/immutability に引っかかるので、先に通し番号の対応表を作っておく。
*/
const drawOrder = new Map(
groups.flatMap((g) => g.items).map((r, i) => [r.branch.id, i]),
);
const fade = `transition-opacity duration-700 ease-out motion-reduce:transition-none ${
active ? "opacity-100" : "opacity-0 motion-reduce:opacity-100"
}`;
return (
<section
ref={rootRef}
aria-labelledby="office-locations-ledger-heading"
className="@container w-full max-w-5xl bg-white"
>
{/* 見出し(英字キッカーは置かない。台帳そのものを主役にする) */}
<header className={`max-w-2xl ${fade}`}>
<h2
id="office-locations-ledger-heading"
className="text-[clamp(1.7rem,4vw,2.4rem)] leading-[1.35] font-bold tracking-tight text-gray-900"
>
近い拠点と、
<span className="relative whitespace-nowrap">
いま開いている拠点
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70"
/>
</span>
</h2>
<p className="mt-5 text-[15px] leading-[1.9] font-medium text-gray-600">
拠点名・所在地・電話・営業時間を同じ列に揃えました。現在時刻から営業状況を判定するので、電話をかける前に「いま開いているか」が分かります。
</p>
</header>
{/* 操作レール(エリア絞り込み / 営業中のみ / 現在時刻) */}
<div
className={`mt-10 flex flex-col gap-4 border-t border-gray-300 pt-4 sm:flex-row sm:items-center sm:justify-between ${fade}`}
style={{ transitionDelay: "120ms" }}
>
<div className="flex flex-wrap items-center gap-x-1 gap-y-2">
<span className="mr-2 text-[11px] font-bold tracking-[0.12em] whitespace-nowrap text-gray-500">
エリア
</span>
{(["すべて", ...areas] as const).map((a) => {
const on = area === a;
const count =
a === "すべて"
? branches.length
: branches.filter((b) => b.area === a).length;
return (
<button
key={a}
type="button"
aria-pressed={on}
onClick={() => setArea(a)}
className={`group relative px-2.5 py-1.5 text-[13px] whitespace-nowrap transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
on
? "font-bold text-gray-900"
: "font-medium text-gray-500 hover:text-gray-900"
}`}
>
{a}
<span
className={`${montserrat.className} ml-1.5 text-[11px] font-medium tabular-nums ${
on ? "text-amber-600" : "text-gray-300"
}`}
>
{count}
</span>
<span
aria-hidden="true"
className={`absolute bottom-0 left-0 h-px w-full origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
on ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
);
})}
</div>
<div className="flex items-center gap-4">
<button
type="button"
role="switch"
aria-checked={onlyOpen}
onClick={() => setOnlyOpen((v) => !v)}
className="group inline-flex items-center gap-2.5 text-[13px] font-medium whitespace-nowrap text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
{/* 角丸を使わない面なので、スイッチも直線だけで組む */}
<span
aria-hidden="true"
className={`relative block h-4 w-8 border transition-colors duration-200 motion-reduce:transition-none ${
onlyOpen
? "border-amber-600 bg-amber-50"
: "border-gray-300 bg-white"
}`}
>
<span
className={`absolute top-[2px] left-[2px] block size-3 transition-transform duration-300 ease-out motion-reduce:transition-none ${
onlyOpen
? "translate-x-4 bg-amber-600"
: "translate-x-0 bg-gray-300"
}`}
/>
</span>
<span className={onlyOpen ? "text-gray-900" : ""}>
営業中のみ
<span
className={`${montserrat.className} ml-1.5 text-[11px] font-medium tabular-nums text-gray-400`}
>
{now ? openCount : "–"}
</span>
</span>
</button>
<p className="flex items-baseline gap-1.5 text-[11px] whitespace-nowrap text-gray-400">
<span>現在</span>
<span
className={`${montserrat.className} text-[13px] font-semibold tabular-nums text-gray-600`}
>
{now
? `${pad(now.getHours())}:${pad(now.getMinutes())}`
: "--:--"}
</span>
</p>
</div>
</div>
{/* 列見出し(md 以上。各行の値にも sr-only のラベルを持たせてある) */}
<div
aria-hidden="true"
className={`mt-6 hidden pb-2.5 ${GRID} ${fade}`}
style={{ transitionDelay: "200ms" }}
>
{columnLabels.map((label) => (
<span
key={label}
className={`${PAD_X} block text-[11px] font-bold tracking-[0.12em] text-gray-500`}
>
{label}
</span>
))}
</div>
{/* 台帳本体 */}
<div className="border-b border-gray-300">
{groups.map((group) => (
<div key={group.area}>
{/* エリア帯(面を塗らず、太めの罫線と小さな見出しだけで束ねる) */}
<div
className={`flex items-baseline gap-3 border-t border-gray-300 pt-5 pb-2 ${fade}`}
style={{ transitionDelay: "240ms" }}
>
<span
aria-hidden="true"
className="size-1.5 flex-none translate-y-[-2px] bg-amber-600"
/>
<h3 className="text-[13px] font-bold tracking-[0.08em] text-gray-900">
{group.area}
</h3>
<span
className={`${montserrat.className} text-[11px] font-medium tabular-nums text-gray-400`}
>
{group.items.length}拠点
</span>
</div>
<ul>
{group.items.map(({ branch, status }) => {
const s = status ? statusStyle[status.kind] : null;
const delay = 320 + (drawOrder.get(branch.id) ?? 0) * 70;
return (
<li
key={branch.id}
className="group relative py-5 transition-colors duration-200 motion-reduce:transition-none @2xl:py-0 @2xl:hover:bg-gray-50/70"
>
{/* 上罫(この線が index 順に引かれることが出現演出そのもの) */}
<span
aria-hidden="true"
className={`absolute top-0 left-0 h-px w-full origin-left bg-gray-200 transition-transform duration-500 ease-out motion-reduce:transition-none ${
active
? "scale-x-100"
: "scale-x-0 motion-reduce:scale-x-100"
}`}
style={{ transitionDelay: `${delay}ms` }}
/>
{/* hover / focus で左端にアンバーの縦罫が降りる(transform のみ) */}
<span
aria-hidden="true"
className="absolute top-0 left-0 h-full w-px origin-top scale-y-0 bg-amber-600 transition-transform duration-300 ease-out group-hover:scale-y-100 group-focus-within:scale-y-100 motion-reduce:transition-none"
/>
{/* 縦罫は「絶対配置の飾り」と兄弟にすると divide-x が飾りまで拾うので、内側で1枚包む */}
<div className={`${GRID} @2xl:divide-x @2xl:divide-gray-100`}>
{/* 拠点名 */}
<div className={CELL}>
<p className="text-[15px] leading-[1.5] font-bold text-gray-900">
{branch.name}
</p>
<p className="mt-1.5 text-[11px] font-bold tracking-[0.12em] text-gray-400">
{branch.kind}
</p>
</div>
{/* 所在地 */}
<div className={`${CELL} mt-3 @2xl:mt-0`}>
<p className="text-[11px] font-bold tracking-[0.12em] text-gray-500 @2xl:sr-only">
所在地
</p>
<p className="mt-1 text-[13px] leading-[1.8] text-gray-700 @2xl:mt-0">
<span
className={`${montserrat.className} mr-1.5 tabular-nums text-gray-400`}
>
〒{branch.zip}
</span>
{branch.address}
<span className="whitespace-nowrap">{branch.lot}</span>{" "}
<span className="whitespace-nowrap">{branch.building}</span>
</p>
{/* 地図リンクは細字テキストリンクに留める(ボタン化も矢印も置かない) */}
<a
href="#"
className="mt-2 inline-block text-[12px] font-normal text-gray-500 underline decoration-gray-300 underline-offset-4 transition-colors duration-200 hover:text-amber-700 hover:decoration-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
地図を開く
<span className="sr-only">({branch.name})</span>
</a>
</div>
{/* 電話 */}
<div className={`${CELL} mt-3 @2xl:mt-0`}>
<p className="text-[11px] font-bold tracking-[0.12em] text-gray-500 @2xl:sr-only">
電話
</p>
<a
href={`tel:${branch.tel.replace(/-/g, "")}`}
className={`${montserrat.className} mt-1 inline-block text-[14px] font-semibold tabular-nums @4xl:text-[15px] text-gray-900 transition-colors duration-200 hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none @2xl:mt-0`}
>
{branch.tel}
</a>
</div>
{/* 営業時間 + 現在の状況 */}
<div className={`${CELL} mt-3 @2xl:mt-0`}>
<p className="text-[11px] font-bold tracking-[0.12em] text-gray-500 @2xl:sr-only">
営業時間
</p>
<p
className={`${montserrat.className} mt-1 flex flex-wrap items-baseline text-[13px] leading-[1.7] font-medium tabular-nums text-gray-700 @2xl:mt-0`}
>
{/* 区切りは行末側に置く。行頭に「/」が来ると読み出しが一瞬つまずく */}
{branch.hours.map((h, i) => (
<span key={h} className="whitespace-nowrap">
{h}
{i < branch.hours.length - 1 && (
<span aria-hidden="true" className="mx-1.5 text-gray-300">
/
</span>
)}
</span>
))}
</p>
<p className="mt-2 flex items-center gap-2 text-[12px] font-bold">
<span
aria-hidden="true"
className={`relative block size-1.5 flex-none ${s ? s.mark : "bg-gray-200"}`}
>
{status?.kind === "open" && (
<span className="absolute inset-0 bg-amber-600 animate-[oll-beat_2.4s_ease-out_infinite] motion-reduce:animate-none" />
)}
</span>
<span className={s ? s.text : "text-gray-300"}>
{status ? status.label : "—"}
</span>
</p>
<p className="mt-2 text-[11px] leading-[1.8] text-gray-500">
{branch.note}
</p>
</div>
</div>
</li>
);
})}
</ul>
</div>
))}
{groups.length === 0 && (
<p className="border-t border-gray-200 py-12 text-center text-[13px] leading-[1.9] text-gray-500">
条件に合う拠点がありません。
<button
type="button"
onClick={() => {
setArea("すべて");
setOnlyOpen(false);
}}
className="ml-1 font-semibold text-gray-900 underline decoration-gray-300 underline-offset-4 hover:text-amber-700 hover:decoration-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
条件を外す
</button>
</p>
)}
</div>
{/* 脚注(台帳の読み方は本文で必ず言い切る) */}
<p
className={`mt-5 text-[12px] leading-[1.9] text-gray-500 ${fade}`}
style={{ transitionDelay: "400ms" }}
>
営業状況はご覧の端末の時刻から算出した目安です。祝日・年末年始は全拠点休業となります。
</p>
{/* このブロック内で完結するキーフレーム(transform / opacity のみ) */}
<style>{`
@keyframes oll-beat {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(3.2); opacity: 0; }
100% { transform: scale(3.2); opacity: 0; }
}
`}</style>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/office-locations-ledger.json