First CH Designs
BLK-48Blocks

Blog Digest — Editorial List

An article list built around estimated reading time. Each row opens with an oversized Latin numeral for the minutes, with a bar beneath whose length is fixed as a ratio to the longest piece — so readers can compare weight at a glance without a single thumbnail. The numeral encodes quantity, not sequence, so it never reads as a step marker the way 01/02 does. Sorting offers newest-first and shortest-first; because sorting never changes the count, an sr-only aria-live announces the new order rather than a number of results. Categories are separated by text colour and a hairline rule instead of outlined badges, and rows carry no fills, shadows or radii at all. Hover moves only two things: an origin-flipping underline wipe on the headline and an opacity overlay on the bar — the bar length never changes, so interaction never distorts the data. No arrow links, no Latin kicker, no rounded corners. Zero images, zero dependencies.

Added:
2026-09-01
Dependencies:
None
tags
#block #blog #column #list #reading-time #sort #hairline #editorial #no-image #no-deps

Preview

読み物BLK-48

いま出せる時間から記事を選ぶ

制作の現場で決めていることを、読み終わるまでの目安つきで公開しています。掲載内容はすべて架空のサンプルです。

並べ替え

新着順に並べ替えました

バーの長さは読了目安(この一覧の最長は15分)を表しています。

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

/**
 * ブログ・コラム抜粋(エディトリアルリスト) / Blog Digest — Editorial List
 *
 * 差別化: 同じ棚の既存2件は「日付で引く一覧」(平坦な罫線リスト+カテゴリ絞り込み)と
 * 「編集部が決めた主従」(固定フィーチャー+タブ)で、どちらも読み手は順番を動かせない。
 * 本ブロックは読了目安を第一の軸に据え、特大の分数と長さで比較できるバーを行の骨格にして、
 * 読み手が「いま出せる時間」で並べ替えて選ぶ型にした(サムネイル画像は使わない)。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 数量を持つ一覧は「行の左端の特大数字+長さで比較できるバー」で律動を作れる。順序ではなく
 *   量を表すので、番号マーカー(01/02)と違って手順に誤読されない。バーの長さは max との比で
 *   固定し、hover では長さを変えずに overlay の opacity だけ上げる(データの見た目を操作で歪めない)。
 * - 並べ替えは絞り込みと違って件数が変わらない。件数の読み上げではなく「何順になったか」を
 *   sr-only の aria-live で伝え、ボタンは aria-pressed で押下状態を持たせる。
 * - カテゴリは枠付きバッジにせず、文字色と hairline の縦罫だけでメタ行を仕分けると、
 *   1行の情報量(分数・カテゴリ・日付・見出し・抄録)が増えても面のノイズが増えない。
 */

type Article = {
  id: string;
  /** 読了目安(分)。行の左端の特大数字とバーの長さの両方に使う */
  minutes: number;
  category: string;
  /** machine-readable(time要素のdatetime) */
  date: string;
  title: string;
  excerpt: string;
};

/** アンバーで立てるカテゴリ(塗らない・枠も持たない) */
const ACCENT_CATEGORIES = new Set(["設計"]);

const articles: Article[] = [
  {
    id: "a-06",
    minutes: 12,
    category: "設計",
    date: "2026-08-28",
    title: "「あとから自分で直せるサイト」は要件定義で決まる",
    excerpt: "公開後にどこを触れるようにするかは、CMSの選定ではなく最初の線引きで決まります。編集できる範囲を合意しておくための質問の並べ方をまとめました。",
  },
  {
    id: "a-05",
    minutes: 6,
    category: "運用",
    date: "2026-08-14",
    title: "問い合わせが増えない理由を、フォームの外側から探す",
    excerpt: "入力欄の改善で拾えるのは最後のひと押しだけです。手前の導線でどこまで判断材料を渡せているかを、実際の閲覧の流れから点検します。",
  },
  {
    id: "a-04",
    minutes: 9,
    category: "デザイン",
    date: "2026-07-30",
    title: "余白と罫線だけで情報を仕分ける",
    excerpt: "枠で囲むほど紙面は説明的になり、要素が増えるほど縁のノイズが積み上がります。囲まずにまとまりを作るための、余白と線1本の使い分けです。",
  },
  {
    id: "a-03",
    minutes: 4,
    category: "制作の裏側",
    date: "2026-07-16",
    title: "写真素材がない案件で、紙面を成立させる手順",
    excerpt: "撮影の予算が取れない案件でも、文字の大きさと余白の設計だけで密度は作れます。着手の順番と、先に決めておく寸法の話です。",
  },
  {
    id: "a-02",
    minutes: 15,
    category: "設計",
    date: "2026-06-27",
    title: "表示速度の改善を、かける手間と並べて考える",
    excerpt: "速度は上げるほど良いものではなく、どこまでを費用と時間に見合う範囲とするかの判断です。効きやすい順に並べ替えた実務用の一覧です。",
  },
  {
    id: "a-01",
    minutes: 7,
    category: "運用",
    date: "2026-06-05",
    title: "更新が止まるサイトに共通する三つの兆候",
    excerpt: "止まる前には必ず前触れがあります。担当者が変わった直後の記事の傾向から、手を入れ直す時期の見分け方を整理しました。",
  },
];

type Order = "latest" | "shortest";

const ORDERS: { value: Order; label: string; live: string }[] = [
  { value: "latest", label: "新着順", live: "新着順に並べ替えました" },
  { value: "shortest", label: "読む時間が短い順", live: "読了目安が短い順に並べ替えました" },
];

/** 2026-08-28 は年を薄く落として月日を主役に(Montserrat・tabular-nums) */
function DateLabel({ date }: { date: string }) {
  const [y, m, d] = date.split("-");
  return (
    <time
      dateTime={date}
      className={`${montserrat.className} text-[12px] font-semibold text-gray-500 tabular-nums`}
    >
      <span className="text-gray-300">{y}.</span>
      {m}.{d}
    </time>
  );
}

export default function BlogDigestEditorial() {
  const rootRef = useRef<HTMLElement>(null);
  const uid = useId();
  const headingId = `${uid}-heading`;
  const [active, setActive] = useState(false);
  const [order, setOrder] = useState<Order>("latest");

  // 画面内に入ったら 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 maxMinutes = useMemo(
    () => articles.reduce((max, a) => (a.minutes > max ? a.minutes : max), 0),
    [],
  );

  const shown = useMemo(() => {
    const list = [...articles];
    return order === "shortest"
      ? list.sort((a, b) => a.minutes - b.minutes || b.date.localeCompare(a.date))
      : list.sort((a, b) => b.date.localeCompare(a.date));
  }, [order]);

  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={headingId}
      className="@container w-full max-w-5xl bg-white px-1 text-gray-900"
    >
      {/* ヘッダー(7:5 の非対称・英字キッカーは置かず和文ラベルと標本IDだけにする) */}
      <header className="grid grid-cols-1 gap-x-8 gap-y-4 @2xl:grid-cols-12">
        <div className="@2xl:col-span-7">
          <p
            className={`flex items-center gap-2.5 text-xs font-bold text-amber-700 ${reveal}`}
            style={{ transitionDelay: "0ms" }}
          >
            <span aria-hidden="true" className="inline-block h-3.5 w-0.5 bg-amber-600" />
            読み物
            <span className={`${montserrat.className} text-[11px] font-semibold text-gray-300 tabular-nums`}>
              BLK-48
            </span>
          </p>
          <h2
            id={headingId}
            className={`mt-4 text-[clamp(1.6rem,3.4vw,2.25rem)] leading-[1.3] font-bold tracking-tight ${reveal}`}
            style={{ transitionDelay: "80ms" }}
          >
            いま出せる時間から記事を選ぶ
          </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
        className={`mt-8 flex flex-wrap items-center gap-x-5 gap-y-2.5 @2xl:mt-10 ${reveal}`}
        style={{ transitionDelay: "240ms" }}
      >
        <span className="text-[12px] font-semibold text-gray-400">並べ替え</span>
        {ORDERS.map((o) => {
          const isActive = order === o.value;
          return (
            <button
              key={o.value}
              type="button"
              aria-pressed={isActive}
              onClick={() => setOrder(o.value)}
              className={`relative pb-1.5 text-[12.5px] font-semibold tracking-wide 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"
              }`}
            >
              {o.label}
              <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">
        {ORDERS.find((o) => o.value === order)?.live}
      </p>

      {/* 一覧本体(カードで囲まない・行の骨格は特大の分数とバー) */}
      <div className={`relative mt-6 ${reveal}`} style={{ transitionDelay: "300ms" }}>
        <ul className="grid list-none grid-cols-1 pl-0">
          {shown.map((item, i) => {
            const accent = ACCENT_CATEGORIES.has(item.category);
            return (
              <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-[3.75rem_minmax(0,1fr)] gap-x-4 py-6 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 @2xl:grid-cols-[6rem_minmax(0,1fr)] @2xl:gap-x-8 @2xl:py-7"
                >
                  {/* 左端: 読了目安。順序ではなく量なので番号マーカーとは意味が違う */}
                  <span className="flex items-baseline gap-1">
                    <span className="sr-only">読了目安</span>
                    <span
                      className={`${montserrat.className} text-[clamp(2rem,5.5vw,3rem)] leading-none font-bold text-gray-900 tabular-nums transition-colors duration-300 ease-out group-hover:text-amber-700 group-focus-visible:text-amber-700 motion-reduce:transition-none`}
                    >
                      {item.minutes}
                    </span>
                    <span className="text-[11px] font-semibold text-gray-400">分</span>
                  </span>

                  <span className="block">
                    {/* メタ行: 枠付きバッジを使わず、文字色と hairline の縦罫で仕分ける */}
                    <span className="flex items-center gap-2.5">
                      <span
                        className={`text-[11px] font-semibold tracking-wider ${
                          accent ? "text-amber-700" : "text-gray-500"
                        }`}
                      >
                        {item.category}
                      </span>
                      <span aria-hidden="true" className="inline-block h-3 w-px bg-gray-200" />
                      <DateLabel date={item.date} />
                    </span>

                    <span className="mt-2 block text-[15px] leading-[1.7] font-bold @2xl:text-[16.5px]">
                      <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>

                    {/* line-clamp は display:-webkit-box を張るので block と併記しない(display が競合して効かなくなる) */}
                    <span className="mt-2 line-clamp-2 text-[13px] leading-[1.9] font-normal text-gray-500">
                      {item.excerpt}
                    </span>

                    {/* 読了目安バー: 長さは最長との比で固定。hover では長さを変えず overlay の opacity だけ上げる */}
                    <span
                      aria-hidden="true"
                      className={`mt-4 block h-0.5 origin-left bg-amber-600/25 transition-transform duration-[900ms] ease-out motion-reduce:scale-x-100 motion-reduce:transition-none ${
                        active ? "scale-x-100" : "scale-x-0"
                      }`}
                      style={{
                        width: `${Math.round((item.minutes / maxMinutes) * 100)}%`,
                        transitionDelay: `${420 + i * 90}ms`,
                      }}
                    >
                      <span className="block h-full w-full bg-amber-600 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none" />
                    </span>
                  </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 @2xl:grid-cols-12">
        <div className={`@2xl:col-span-7 ${reveal}`} style={{ transitionDelay: "900ms" }}>
          <a
            href="#"
            className="group inline-flex items-center 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>
          </a>
        </div>
        <p
          className={`text-[12.5px] leading-[1.9] text-gray-500 @2xl:col-span-5 @2xl:self-center ${reveal}`}
          style={{ transitionDelay: "980ms" }}
        >
          バーの長さは読了目安(この一覧の最長は{maxMinutes}分)を表しています。
        </p>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/blog-digest-editorial.json