First CH Designs
BLK-21Blocks

お知らせ・ニュース一覧(罫線リスト)

お知らせを「日付+カテゴリタグ+タイトル+矢印」の1行にまとめ、hairline罫線だけで区切る縦リスト。カード(面・影・角丸)を一切使わないので、件数が増えても縁のノイズが積み上がらない。ニュースは順序を持つ内容ではないため 01/02 の番号マーカーは置かず、行の識別は time要素の日付とカテゴリタグに担わせる。モバイルは日付+カテゴリを1行にまとめ、md以上では md:contents で wrapper を消して子をそのままgridセルへ昇格させる(同一DOMで2レイアウト)。ホバーはタイトル下線の origin 反転ワイプと矢印の2px移動の2つだけに絞り、面の変化を足さない。カテゴリフィルタも面を持たないテキストボタン+アンダーラインで、aria-pressed と sr-only の aria-live で状態を伝える。画像素材ゼロ・依存ゼロ(他社サイトに見られる罫線縦リスト構造を参考にブランド再構築・2026-08-12)。

追加日:
2026-08-12
依存:
なし
tags
#block #news #updates #list #hairline #filter #editorial #no-image #no-deps

プレビュー

BLK-21 — News / お知らせ

お知らせ

休業案内から制作実績の公開まで、会社からの更新をまとめて掲載しています。掲載内容はすべて架空のサンプルです。

6件を表示しています

過去の掲載分は年別アーカイブから確認できます。

"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",
});

/**
 * お知らせ・ニュース一覧(罫線リスト) / News & Updates — Hairline List
 * 参考: 他社サイトに見られる「hairline罫線だけで区切る縦リスト」構造をブランド再構築
 * (コピーではない)。日付・カテゴリ・見出しはすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 罫線リスト(カード化しない一覧): 1件=1本の hairline(border ではなく absolute な h-px)で
 *   区切り、面(bg・shadow・rounded)を一切与えない。情報量の多い一覧ほどカードの縁が視覚ノイズに
 *   なるので、区切りは線1本+余白(py-5 / md:py-6)だけに任せる。最下辺の線は空の <li> を足さず、
 *   ul の親(relative)へ absolute inset-x-0 bottom-0 h-px で置く=SRに空行を数えさせない。
 * - 番号マーカーを付けない: ニュースは順序を持つ内容ではない。01/02… を振ると「手順」に読めるため、
 *   行の識別子は日付(time要素)とカテゴリタグに担わせる。順序のある内容(process-steps)とは逆。
 * - 1行の情報設計: 日付 / カテゴリ / タイトル / 矢印 の4カラム。モバイルは grid を組み替え、
 *   日付+カテゴリの meta 群を md:contents で「wrapperごと消して子をgridセルに昇格」させると、
 *   同じDOMのままモバイル2段・デスクトップ1行を両立できる(要素の重複記述が不要)。
 * - ホバーは2つだけ: タイトル下線の origin 反転ワイプ(rest: origin-right scale-x-0 →
 *   hover: origin-left scale-x-100。離すと右へ抜ける)と、矢印の translate-x-0.5(=2px)。
 *   行全体を塗る・線を太らせる等の「面の変化」を足さないのが、罫線リストが崩れない条件。
 * - 罫線の登場: 各行の上端 hairline を origin-left の scale-x-0 → 100 で上から順に引く
 *   (delay 320+index*90ms)。リフローゼロで「線が引かれていく」紙面感が出る。
 * - フィルタ: 面を持たないテキストボタン+アンダーラインのみ。aria-pressed で状態を伝え、
 *   件数変化は sr-only の aria-live="polite" で通知する。
 */

type Tone = "neutral" | "accent";

type NewsItem = {
  id: string;
  /** machine-readable(time要素のdatetime) */
  date: string;
  category: string;
  title: string;
  isNew?: boolean;
};

const CATEGORY_TONE: Record<string, Tone> = {
  お知らせ: "neutral",
  制作実績: "accent",
  採用: "neutral",
  メディア: "neutral",
};

const items: NewsItem[] = [
  {
    id: "n-06",
    date: "2026-08-05",
    category: "お知らせ",
    title: "夏季休業(8/13〜8/16)のご案内",
    isNew: true,
  },
  {
    id: "n-05",
    date: "2026-07-28",
    category: "制作実績",
    title: "地域工務店のコーポレートサイトを公開しました",
    isNew: true,
  },
  {
    id: "n-04",
    date: "2026-07-10",
    category: "採用",
    title: "Webディレクター(正社員)の募集を開始しました",
  },
  {
    id: "n-03",
    date: "2026-06-24",
    category: "メディア",
    title: "地域メディアに制作事例が掲載されました",
  },
  {
    id: "n-02",
    date: "2026-06-02",
    category: "制作実績",
    title: "歯科クリニックのサイトをリニューアルしました",
  },
  {
    id: "n-01",
    date: "2026-05-19",
    category: "お知らせ",
    title: "保守・運用プランの内容を改定しました",
  },
];

const ALL = "すべて";

/** 2026-08-05 → 年は薄く、月日を主役に(Montserrat・tabular-nums) */
function DateLabel({ date }: { date: string }) {
  const [y, m, d] = date.split("-");
  return (
    <time
      dateTime={date}
      className={`${montserrat.className} text-[13px] font-semibold tracking-[0.06em] text-gray-900 tabular-nums`}
    >
      <span className="text-gray-400">{y}.</span>
      {m}.{d}
    </time>
  );
}

export default function NewsList() {
  const rootRef = useRef<HTMLElement>(null);
  const [active, setActive] = useState(false);
  const [filter, setFilter] = useState<string>(ALL);

  // 画面内に入ったら reveal 開始(IO 非対応環境は即開始)。reduced-motion は
  // motion-reduce: ユーティリティで初期状態=最終状態に畳むので JS 判定は不要。
  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.15 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // フィルタ(すべて + 出現順のカテゴリ)と件数
  const filters = useMemo(() => {
    const seen: string[] = [];
    for (const it of items) if (!seen.includes(it.category)) seen.push(it.category);
    return [ALL, ...seen].map((label) => ({
      label,
      count: label === ALL ? items.length : items.filter((i) => i.category === label).length,
    }));
  }, []);

  const shown = filter === ALL ? items : items.filter((i) => i.category === filter);

  const reveal = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
    active
      ? "translate-y-0 opacity-100"
      : "translate-y-5 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
  }`;

  return (
    <section
      ref={rootRef}
      aria-labelledby="news-list-heading"
      className="w-full max-w-5xl bg-white px-1"
    >
      {/* ヘッダー(7:5 の非対称) */}
      <header className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-12">
        <div className="md:col-span-7">
          <p
            className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
            style={{ transitionDelay: "0ms" }}
          >
            BLK-21 — News / お知らせ
          </p>
          <h2
            id="news-list-heading"
            className={`mt-4 flex items-baseline gap-3 text-[clamp(1.6rem,3.4vw,2.25rem)] leading-[1.3] font-bold tracking-tight text-gray-900 ${reveal}`}
            style={{ transitionDelay: "80ms" }}
          >
            お知らせ
            <span
              className={`${montserrat.className} text-[12px] font-semibold tracking-[0.22em] text-gray-300 uppercase`}
              aria-hidden="true"
            >
              News
            </span>
          </h2>
        </div>
        <p
          className={`text-[13.5px] leading-[1.95] font-medium text-gray-600 md:col-span-5 md:self-end ${reveal}`}
          style={{ transitionDelay: "160ms" }}
        >
          休業案内から制作実績の公開まで、会社からの更新をまとめて掲載しています。掲載内容はすべて架空のサンプルです。
        </p>
      </header>

      {/* カテゴリフィルタ(面を持たないテキストボタン+アンダーライン) */}
      <div
        className={`mt-8 flex flex-wrap items-center gap-x-5 gap-y-2.5 md:mt-10 ${reveal}`}
        style={{ transitionDelay: "240ms" }}
      >
        {filters.map((f) => {
          const isActive = filter === f.label;
          return (
            <button
              key={f.label}
              type="button"
              aria-pressed={isActive}
              onClick={() => setFilter(f.label)}
              className={`relative pb-1.5 text-[12.5px] font-semibold tracking-[0.08em] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 ${
                isActive ? "text-gray-900" : "text-gray-400 hover:text-gray-700"
              }`}
            >
              {f.label}
              <span
                className={`${montserrat.className} ml-1.5 text-[10px] font-semibold text-gray-300 tabular-nums`}
              >
                {f.count}
              </span>
              <span className="sr-only">件</span>
              <span
                aria-hidden="true"
                className={`absolute inset-x-0 bottom-0 h-px origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                  isActive ? "scale-x-100" : "scale-x-0"
                }`}
              />
            </button>
          );
        })}
      </div>

      <p aria-live="polite" className="sr-only">
        {shown.length}件を表示しています
      </p>

      {/* 罫線リスト本体(カードで囲まない) */}
      <div
        className={`relative mt-6 ${reveal}`}
        style={{ transitionDelay: "300ms" }}
      >
        <ul className="grid list-none grid-cols-1 pl-0">
          {shown.map((item, i) => (
            <li key={item.id} className="relative">
              {/* 上端の hairline(上から順に引かれる) */}
              <span
                aria-hidden="true"
                className={`absolute inset-x-0 top-0 h-px origin-left bg-gray-200 transition-transform duration-[900ms] ease-out motion-reduce:scale-x-100 motion-reduce:transition-none ${
                  active ? "scale-x-100" : "scale-x-0"
                }`}
                style={{ transitionDelay: `${320 + i * 90}ms` }}
              />
              <a
                href="#"
                className="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-2 py-5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 md:grid-cols-[6.5rem_5.5rem_minmax(0,1fr)_1rem] md:items-baseline md:gap-x-5 md:py-6"
              >
                {/* 日付+カテゴリ: モバイルは1行にまとめ、md以上では contents で親を消してgridセルに昇格 */}
                <span className="col-span-2 flex items-center gap-3 md:contents">
                  <DateLabel date={item.date} />
                  <span
                    className={`inline-flex items-center justify-center border px-2 py-[3px] text-[11px] leading-none font-semibold tracking-[0.08em] whitespace-nowrap md:justify-self-start ${
                      CATEGORY_TONE[item.category] === "accent"
                        ? "border-amber-600/40 text-amber-700"
                        : "border-gray-200 text-gray-500"
                    }`}
                  >
                    {item.category}
                  </span>
                </span>

                <span className="text-[15px] leading-[1.75] font-medium text-gray-900">
                  {item.isNew ? (
                    <>
                      <span
                        aria-hidden="true"
                        className="mr-2 inline-block size-1.5 -translate-y-[2px] rounded-full bg-amber-600 align-middle"
                      />
                      <span className="sr-only">新着 </span>
                    </>
                  ) : null}
                  <span className="relative inline-block">
                    {item.title}
                    <span
                      aria-hidden="true"
                      className="absolute -bottom-0.5 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 group-focus-visible:origin-left group-focus-visible:scale-x-100 motion-reduce:transition-none"
                    />
                  </span>
                </span>

                <span
                  aria-hidden="true"
                  className="self-center text-[13px] text-gray-300 transition-transform duration-300 ease-out group-hover:translate-x-0.5 group-focus-visible:translate-x-0.5 motion-reduce:transition-none md:self-baseline"
                >
                  →
                </span>
              </a>
            </li>
          ))}
        </ul>
        {/* 表を閉じる最下辺(空の <li> を足さない) */}
        <span
          aria-hidden="true"
          className={`absolute inset-x-0 bottom-0 h-px origin-left bg-gray-200 transition-transform duration-[900ms] ease-out motion-reduce:scale-x-100 motion-reduce:transition-none ${
            active ? "scale-x-100" : "scale-x-0"
          }`}
          style={{ transitionDelay: `${320 + shown.length * 90}ms` }}
        />
      </div>

      {/* フッター(テキストCTA・下線ワイプ) */}
      <div className="mt-10 grid grid-cols-1 gap-4 md:grid-cols-12">
        <div
          className={`md:col-span-7 ${reveal}`}
          style={{ transitionDelay: "900ms" }}
        >
          <a
            href="#"
            className="group inline-flex items-center gap-2 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
          >
            <span className="relative">
              お知らせ一覧を見る
              <span
                aria-hidden="true"
                className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
              />
              <span
                aria-hidden="true"
                className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
              />
            </span>
            <span
              aria-hidden="true"
              className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
            >
              →
            </span>
          </a>
        </div>
        <p
          className={`text-[12.5px] leading-[1.9] text-gray-500 md:col-span-5 md:self-center ${reveal}`}
          style={{ transitionDelay: "980ms" }}
        >
          過去の掲載分は年別アーカイブから確認できます。
        </p>
      </div>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/news-list.json