First CH Designs
BLK-21Blocks

News & Updates — Hairline List

A news list that packs each entry into one line — date, category tag, title, arrow — separated by hairline rules alone. With no cards (no fills, shadows or radii), the visual noise of borders never accumulates as the list grows. Because news carries no inherent order there are no 01/02 markers; a <time> date and a category tag identify each row instead. On mobile the date and tag share one line, while md: uses display:contents to dissolve the wrapper and promote its children straight into the grid — one DOM, two layouts. Hover is limited to two things: an origin-flipping underline wipe on the title and a 2px arrow nudge; nothing changes fill. The category filter is likewise fill-free — text buttons with an underline, state exposed via aria-pressed and an sr-only aria-live count. Zero images, zero dependencies.

Added:
2026-08-12
Dependencies:
None
tags
#block #news #updates #list #hairline #filter #editorial #no-image #no-deps

Preview

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

Add to your project via shadcn CLI

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