地域の設計事務所のコーポレートサイトを公開しました
施工事例を軸に構成を組み直し、写真の少ない段階でも成立する誌面設計にしました。公開後の更新は管理画面から行えます。
詳しく見るA news block that introduces hierarchy: the single latest entry is featured on the left (date, hairline category badge, large headline, excerpt, text CTA) while the rest collapse into a hairline list on the right. The difference is carried by type scale and whitespace alone — no fills, no cards — so the layout holds up with zero image assets. A single vertical rule divides the two columns and swaps to a horizontal rule when it folds to one column: same DOM, different divider. Filtering is a real tab UI (role="tablist" with roving tabindex, arrow/Home/End keys) and switching a tab also swaps the featured item. Panels remount via a changing key so a CSS animation always replays from the top, with rows staggered 55ms apart. Dates are set in tabular Latin numerals with the year dimmed; categories are outline-only badges. Empty and single-item states are designed in, so no hollow rules are left behind. Zero images, zero dependencies.
BLK-37 — Pickup / 新着
いちばん新しい更新を大きく、残りは日付順のリストで。カテゴリを選ぶと主役も入れ替わります。掲載内容はすべて架空のサンプルです。
過去の掲載分は年別アーカイブから確認できます。
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* お知らせ②ピックアップ+カテゴリタブ(BLK-37)
*
* 新着の「主従」を作るブロック。最新1件だけを左に大きく置き、残りは右の罫線リストに畳む。
* 掲載しているテキスト・日付はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
*
* 1. 一覧に「主従」を持ち込むと、画像がなくても紙面が立つ
* 全件を等価に並べると平坦になる。最新1件をフィーチャー(大見出し+抄録)に昇格させ、
* 残りは1行1件の罫線リストへ落とす。差を付けるのは**面や色ではなく文字サイズと余白**
* (フィーチャー: clamp で 20〜27px・リスト: 14px)。カードを足さないので縁が増えない。
*
* 2. 主従の分割線は縦罫1本だけ
* 2カラムの境界は @2xl:border-l の hairline 1本に留め、左右に背景色を付けない。
* 1カラムに畳んだときは縦罫が横位置の意味を失うので border-l を消し、
* 代わりに上端の hairline(border-t)で区切る=同じDOMで区切り方だけ入れ替える。
*
* 3. タブは role="tablist" + roving tabindex
* 絞り込みをボタンの羅列で組むと支援技術に「4つのうち2つ目」が伝わらない。
* 選択中のみ tabIndex=0、←→/Home/End で移動+同時に選択(自動活性化)。
* 見た目は塗らず、下線 h-px を origin-left の scale-x で出す(面を持たせない絞り込み)。
*
* 4. パネル切替のアニメーションは key の付け替え+ animation で
* transition は「切替後の1フレーム」を待つ必要があり取りこぼす。パネルに key={active} を
* 与えて再マウントさせ、CSS animation を1本当てると常に頭から再生される。
* リストの各行は animationDelay を index*55ms でずらすとカテゴリ切替が"差し替わる"感じになる。
*
* 5. keyframes を持つブロックの reduced-motion は同じ <style> 内で打ち消す
* DOM末尾の <style> は head のユーティリティより後勝ちするため、Tailwind の motion-reduce:
* だけでは opacity:0 のまま固まることがある。@media (prefers-reduced-motion: reduce) で
* animation: none !important を同居させる。
*
* 6. 日付は欧文タブラー・年は落とす
* Montserrat + tabular-nums で桁を固定し、「2026.」だけ text-gray-400 に落として月日を主役に。
* 和文ゴシックのままだと数字幅が揃わず、行頭の日付カラムがガタつく。
*
* 7. 空カテゴリを設計に含める
* 絞り込みは「0件」「1件(=フィーチャーのみで従が無い)」が必ず起きる。
* 従リストが空のときは罫線ごと消して補足文に差し替える(空の罫線枠を残さない)。
*/
type CategoryKey = "all" | "info" | "works" | "recruit";
type NewsItem = {
id: string;
/** machine-readable(time要素のdatetime) */
date: string;
category: Exclude<CategoryKey, "all">;
title: string;
/** フィーチャー時のみ表示する抄録 */
excerpt: string;
};
const CATEGORIES: { key: CategoryKey; label: string; en: string }[] = [
{ key: "all", label: "すべて", en: "All" },
{ key: "info", label: "お知らせ", en: "Info" },
{ key: "works", label: "実績", en: "Works" },
{ key: "recruit", label: "採用", en: "Recruit" },
];
const CATEGORY_LABEL: Record<Exclude<CategoryKey, "all">, string> = {
info: "お知らせ",
works: "実績",
recruit: "採用",
};
/** 新しい順に並べておく(先頭がフィーチャー候補) */
const ITEMS: NewsItem[] = [
{
id: "n-08",
date: "2026-08-18",
category: "works",
title: "地域の設計事務所のコーポレートサイトを公開しました",
excerpt:
"施工事例を軸に構成を組み直し、写真の少ない段階でも成立する誌面設計にしました。公開後の更新は管理画面から行えます。",
},
{
id: "n-07",
date: "2026-08-05",
category: "info",
title: "夏季休業(8/13〜8/16)のご案内",
excerpt:
"期間中のお問い合わせは翌営業日以降に順次ご返信します。公開中のサイトの障害対応は通常どおり承ります。",
},
{
id: "n-06",
date: "2026-07-29",
category: "recruit",
title: "Webディレクター(正社員)の募集を開始しました",
excerpt:
"要件整理から公開後の運用までを一貫して担当するポジションです。オンライン面談のみでの選考も可能です。",
},
{
id: "n-05",
date: "2026-07-14",
category: "works",
title: "歯科クリニックのサイトをリニューアルしました",
excerpt:
"予約導線を先頭に置き直し、診療時間の表を狭幅でも読める組みに変更しました。",
},
{
id: "n-04",
date: "2026-06-30",
category: "info",
title: "保守・運用プランの内容を改定しました",
excerpt: "月次のバックアップと更新代行の範囲を明確化しています。",
},
{
id: "n-03",
date: "2026-06-11",
category: "works",
title: "食品メーカーの採用特設ページを制作しました",
excerpt: "現場の1日を追う構成で、社員紹介を主役に据えました。",
},
{
id: "n-02",
date: "2026-05-22",
category: "recruit",
title: "インターンシップ(Web制作コース)の受付を始めました",
excerpt: "実案件の一部を担当しながら、制作の流れをひととおり体験できます。",
},
];
const countOf = (key: CategoryKey) =>
key === "all" ? ITEMS.length : ITEMS.filter((item) => item.category === key).length;
/** 2026-08-05 → 年は薄く、月日を主役に(Montserrat・tabular-nums) */
function DateLabel({ date, className = "" }: { date: string; className?: string }) {
const [y, m, d] = date.split("-");
return (
<time
dateTime={date}
className={`${montserrat.className} tracking-[0.06em] tabular-nums ${className}`}
>
<span className="text-gray-400">{y}.</span>
{m}.{d}
</time>
);
}
/** 細枠バッジ(塗らない) */
function CategoryBadge({
category,
className = "",
}: {
category: Exclude<CategoryKey, "all">;
className?: string;
}) {
return (
<span
className={`inline-flex items-center justify-center border px-2 py-[3px] text-[11px] leading-none font-semibold tracking-[0.08em] whitespace-nowrap ${
category === "works"
? "border-amber-600/40 text-amber-700"
: "border-gray-200 text-gray-500"
} ${className}`}
>
{CATEGORY_LABEL[category]}
</span>
);
}
export default function NewsPickupTabs() {
const uid = useId();
const rootRef = useRef<HTMLElement>(null);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [active, setActive] = useState<CategoryKey>("all");
const [revealed, setRevealed] = useState(false);
// 画面内に入ったら reveal 開始(IO 非対応環境は即開始)。reduced-motion は
// motion-reduce: ユーティリティで初期状態=最終状態に畳むので JS 判定は不要。
useEffect(() => {
const el = rootRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
const id = requestAnimationFrame(() => setRevealed(true));
return () => cancelAnimationFrame(id);
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setRevealed(true);
io.disconnect();
}
},
{ threshold: 0.15 },
);
io.observe(el);
return () => io.disconnect();
}, []);
const { featured, rest } = useMemo(() => {
const list = active === "all" ? ITEMS : ITEMS.filter((item) => item.category === active);
return { featured: list[0] ?? null, rest: list.slice(1, 5) };
}, [active]);
const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const last = CATEGORIES.length - 1;
let next: number | null = null;
if (event.key === "ArrowRight") next = index === last ? 0 : index + 1;
else if (event.key === "ArrowLeft") next = index === 0 ? last : index - 1;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = last;
if (next === null) return;
event.preventDefault();
setActive(CATEGORIES[next].key);
tabRefs.current[next]?.focus();
};
const reveal = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
revealed
? "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={`${uid}-title`}
className="@container w-full max-w-5xl bg-white px-1 text-gray-900"
>
{/* ヘッダー(7:5 の非対称) */}
<header className="grid grid-cols-1 gap-x-8 gap-y-4 @2xl:grid-cols-12">
<div className="@2xl:col-span-7">
<p
className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
style={{ transitionDelay: "0ms" }}
>
BLK-37 — Pickup / 新着
</p>
<h2
id={`${uid}-title`}
className={`mt-4 flex items-baseline gap-3 text-[clamp(1.6rem,3.4vw,2.25rem)] leading-[1.3] font-bold tracking-tight ${reveal}`}
style={{ transitionDelay: "80ms" }}
>
新着情報
<span
className={`${montserrat.className} text-[12px] font-semibold tracking-[0.22em] text-gray-300 uppercase`}
aria-hidden="true"
>
Pickup
</span>
</h2>
</div>
<p
className={`text-[13.5px] leading-[1.95] font-medium text-gray-600 @2xl:col-span-5 @2xl:self-end ${reveal}`}
style={{ transitionDelay: "160ms" }}
>
いちばん新しい更新を大きく、残りは日付順のリストで。カテゴリを選ぶと主役も入れ替わります。掲載内容はすべて架空のサンプルです。
</p>
</header>
{/* カテゴリタブ(塗らない・下線だけ) */}
<div
role="tablist"
aria-label="新着情報のカテゴリ"
className={`mt-8 flex flex-wrap items-center gap-x-6 gap-y-2.5 @2xl:mt-10 ${reveal}`}
style={{ transitionDelay: "240ms" }}
>
{CATEGORIES.map((category, index) => {
const selected = category.key === active;
return (
<button
key={category.key}
ref={(node) => {
tabRefs.current[index] = node;
}}
type="button"
role="tab"
id={`${uid}-tab-${category.key}`}
aria-selected={selected}
aria-controls={`${uid}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(category.key)}
onKeyDown={(event) => onKeyDown(event, index)}
className={`relative pb-1.5 text-[12.5px] font-semibold tracking-[0.08em] transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none ${
selected ? "text-gray-900" : "text-gray-400 hover:text-gray-700"
}`}
>
{category.label}
<span
className={`${montserrat.className} ml-1.5 text-[10px] font-semibold text-gray-300 tabular-nums`}
>
{String(countOf(category.key)).padStart(2, "0")}
</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 ${
selected ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
);
})}
</div>
{/* 本体(主=フィーチャー1件/従=罫線リスト) */}
<div
className={`relative mt-6 ${reveal}`}
style={{ transitionDelay: "300ms" }}
>
{/* 上端 hairline は origin-left の scale-x で引く */}
<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 ${
revealed ? "scale-x-100" : "scale-x-0"
}`}
style={{ transitionDelay: "340ms" }}
/>
<div
key={active}
role="tabpanel"
id={`${uid}-panel`}
aria-labelledby={`${uid}-tab-${active}`}
tabIndex={-1}
className="grid grid-cols-1 gap-x-10 pt-7 @2xl:grid-cols-[minmax(0,1.08fr)_minmax(0,1fr)] @2xl:pt-8"
>
{/* 主: 最新1件 */}
{featured ? (
<article className="pickup-feature">
<a
href="#"
className="group block focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="flex items-center gap-3">
<DateLabel
date={featured.date}
className="text-[13px] font-semibold text-gray-900"
/>
<CategoryBadge category={featured.category} />
<span
className={`${montserrat.className} ml-auto text-[10px] font-bold tracking-[0.22em] text-amber-600 uppercase`}
aria-hidden="true"
>
Latest
</span>
</span>
<h3 className="mt-4 text-[clamp(1.25rem,2.4vw,1.7rem)] leading-[1.5] font-bold tracking-tight">
<span className="relative inline">
{featured.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>
</h3>
<p className="mt-4 text-[13.5px] leading-[1.95] text-gray-600">{featured.excerpt}</p>
<span className="mt-5 inline-flex items-center gap-2 text-[13px] font-semibold text-gray-900">
詳しく見る
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 group-focus-visible:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</span>
</a>
</article>
) : (
<p className="pickup-feature text-[13.5px] leading-[1.95] text-gray-500">
この分類の新着はまだありません。
</p>
)}
{/* 従: 罫線リスト(1カラム時は上端の横罫、2カラム時は縦罫で仕切る) */}
<div className="relative mt-10 border-t border-gray-200 pt-2 @2xl:mt-0 @2xl:border-t-0 @2xl:border-l @2xl:border-gray-200 @2xl:pt-0 @2xl:pl-10">
{rest.length > 0 ? (
<ul className="grid list-none grid-cols-1 pl-0">
{rest.map((item, index) => (
<li
key={item.id}
className="pickup-row border-b border-gray-200 last:border-b-0"
style={{ animationDelay: `${index * 55}ms` }}
>
<a
href="#"
className="group grid grid-cols-1 gap-1.5 py-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
<span className="flex items-center gap-3">
<DateLabel
date={item.date}
className="text-[12px] font-semibold text-gray-900"
/>
<CategoryBadge category={item.category} />
<span
aria-hidden="true"
className="ml-auto text-[12px] 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"
>
→
</span>
</span>
<span className="text-[14px] leading-[1.75] font-medium">
<span className="relative inline">
{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>
</a>
</li>
))}
</ul>
) : (
/* 空の罫線枠を残さない(従が無いときは補足文だけ) */
<p className="py-4 text-[12.5px] leading-[1.9] text-gray-500">
この分類の更新は上の1件のみです。
</p>
)}
</div>
</div>
</div>
{/* フッター(テキストCTA・下線ワイプ) */}
<div className="mt-10 grid grid-cols-1 gap-4 @2xl:grid-cols-12">
<div className={`@2xl:col-span-7 ${reveal}`} style={{ transitionDelay: "900ms" }}>
<a
href="#"
className="group inline-flex items-center gap-2 text-sm font-semibold 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 @2xl:col-span-5 @2xl:self-center ${reveal}`}
style={{ transitionDelay: "980ms" }}
>
過去の掲載分は年別アーカイブから確認できます。
</p>
</div>
<style>{`
@keyframes pickup-feature-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pickup-row-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.pickup-feature { animation: pickup-feature-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.pickup-row { animation: pickup-row-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.pickup-feature, .pickup-row { animation: none !important; }
}
`}</style>
</section>
);
}
npx shadcn@latest add https://designs.first-ch.com/r/news-pickup-tabs.json