First CH Designs
BLK-50Blocks

拠点・店舗一覧(罫線グリッド)

複数拠点を1枚の台帳として横断比較させる一覧ブロック。拠点名・所在地・電話・営業時間を同じ列に揃え、角丸も影も面の塗りも使わず、横罫と縦罫の直交だけで律動を作る。現在時刻から各拠点の営業状況(営業中/まもなく終了/本日休業)を算出して表示し、エリア絞り込みと「営業中のみ」の2操作で候補を絞れる。電話は tel: リンク+タブラー数字、地図は細字のテキストリンクに留めた。地図・写真を持たないため画像素材ゼロで成立する。

追加日:
2026-09-03
依存:
なし
tags
#locations #directory #hairline #grid #hours #tel #filter #tabular-nums #no-image

プレビュー

近い拠点と、いま開いている拠点

拠点名・所在地・電話・営業時間を同じ列に揃えました。現在時刻から営業状況を判定するので、電話をかける前に「いま開いているか」が分かります。

エリア

現在--:--

東日本

2拠点

中部

2拠点

西日本

2拠点

営業状況はご覧の端末の時刻から算出した目安です。祝日・年末年始は全拠点休業となります。

"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