First CH Designs
BLK-37Blocks

お知らせ②ピックアップ+カテゴリタブ

新着に「主従」を持ち込んだお知らせブロック。最新1件だけを左に大きくフィーチャーし(日付+細枠カテゴリバッジ+大見出し+抄録+テキストCTA)、残りは右の罫線リストへ畳む。差を付けるのは面や色ではなく文字サイズと余白だけなので、画像素材ゼロでも紙面が立つ。2カラムの境界は縦罫1本、1カラムに畳んだときは横罫へ入れ替える(同じDOMで区切り方だけ変える)。カテゴリ絞り込みは role="tablist" + roving tabindex の正しいタブUIで、←→/Home/End で移動しつつ主役も入れ替わる。切替は key の付け替えによる再マウント+CSS animation で常に頭から再生し、行は 55ms ずつスタッガー。日付は欧文タブラーで年だけ薄く落とし、カテゴリは塗らない細枠バッジ。0件・1件(従が無い)の状態まで設計に含め、空の罫線枠を残さない。画像素材ゼロ・依存ゼロ。

追加日:
2026-08-24
依存:
なし
tags
#block #news #updates #featured #tabs #hairline #editorial #no-image #no-deps

プレビュー

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

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

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