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