First CH Designs
BLK-73Blocks

Empty, 404 and No-Result States

Three "nothing here" screens — 404, zero search results, and an empty list — laid out together on one ledger instead of hidden behind tabs, so the three ways of wording them can be compared side by side. No apology headline: each band carries one factual sentence plus exactly two available actions, with the cap printed at the right edge of the band header. The 404 band does not stop at a single Home button; it exposes the site's main directory as a two-column hairline list to catch would-be bounces. The zero-result band really searches, and its primary action ("drop the last character") walks the query back to a hit. The empty-list band shows the shape of the rows to come as dashed ghost items, then fills the same slots once you create one. No illustrations — type, hairlines and whitespace only. State swaps use a re-keyed CSS animation limited to opacity and a 3px translateY, announced through role="status" with aria-live="polite". Zero radii, zero images, zero dependencies.

Added:
2026-09-22
Dependencies:
None
tags
#block #empty-state #error #not-found #search #hairline #accessibility #no-image #no-deps

Preview

何も無い画面

BLK-73

見つからない・0件・まだ無いの3つを、切り替えずに並べて置いた台帳です。謝る文章を主役にせず、そこから取れる行動を2つだけ残しています。下の2帯は実際に動きます。

404

ページが見つからない

行動 2 + 主要導線

404

このアドレスのページはありません

移転したか、公開を終えた可能性があります。行き先は右の一覧からも選べます。

サイト内を検索する
0件

検索結果がない

行動 2

0 / 6

問い合わせフォームの離脱率」に一致する記事はありません

語が長いほど一致しにくくなります。まずは末尾を削るか、条件を外して全件から探してください。

一覧にまだ1件も無い

行動 2

表示できる項目はまだありません

最初の1件を作ると、右の枠と同じ形で並びます。中身を見てから決めたいときはサンプルを読み込んでください。

"use client";

import { useMemo, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["500", "600", "700"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 何も無い画面(404・検索結果0件・一覧が空)
 * 既存の一覧・タブ系(罫線リスト、カテゴリ切替、ピックアップ切替)はいずれも「中身がある状態」を
 * 並べる型で、中身が無いときの画面を扱うものは1件も無い。本ブロックは3つの空状態を切替タブで
 * 隠さず、1枚の台帳に同時に並べて見比べられるようにし、各帯に「取れる行動は2つまで」という
 * 制約を数字で明示した。うち2帯は実際に動く(0件は語を削る/空一覧は1件足す)ので、空から
 * 中身へ戻る経路そのものが見本になる。飽和idiomの英字キッカーと末尾の矢印リンクは使わず、
 * 角丸とスクロール出現アニメもゼロ。掲載文はすべてオリジナル。画像素材ゼロ・依存ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **空状態の主役は謝罪文ではなく次の行動**: 「申し訳ございません」を大きく出さず、事実の1文
 *   (何が無いのか)+行動2つに絞る。行動を3つ以上置くと「選べない」状態が新たに生まれるので、
 *   帯の見出し側に「行動 2」と明示して設計上の上限にする。
 * - **404は離脱の受け皿として主要導線を出す**: 戻り先が1つ(トップ)だけだと直帰する。
 *   サイトの主要ディレクトリを2列の罫線リストで置き、ユーザーが自分で行き先を選べるようにする。
 * - **空の器は形を見せる**: 一覧が0件のときは高さゼロで畳まず、これから入る行の形を
 *   border-dashed のゴースト行(幅違いの h-2 バー2本)で見せる。何が表示される場所なのかが
 *   分かり、追加後のレイアウト変化も小さくなる。
 * - **空状態は role="status" + aria-live="polite"**: 検索で0件になった瞬間は視覚的にしか
 *   伝わらない。状態の入れ替えを同じノードで行い、live region に文言を載せる。
 * - **行動は「実際に効くもの」を置く**: 0件のときの第一手は語を減らすこと。1文字削る操作を
 *   ボタンにするとヒットに戻れるため、ゼロ件から抜ける手数が1回で済む。
 * - モーションは状態の入れ替えだけ(opacity と translate-y の3px)。空状態は突然現れる面なので、
 *   スクロール連動のスタッガーは掛けない。motion-reduce:transition-none で即時表示に畳む。
 */

type Doc = { title: string; tag: string };

/** 帯02の検索対象(すべて架空のサンプル) */
const docs: Doc[] = [
  { title: "公開前に確認する表示チェックの手順", tag: "制作" },
  { title: "問い合わせ件数の数え方と計測の設定", tag: "計測" },
  { title: "写真を差し替えるときの推奨サイズ", tag: "運用" },
  { title: "スマートフォンでの読みやすさの基準", tag: "制作" },
  { title: "更新作業の依頼から反映までの流れ", tag: "運用" },
  { title: "独自ドメインの移管で気をつける点", tag: "技術" },
];

/** 帯01の主要導線(404の受け皿) */
const directory: { group: string; links: string[] }[] = [
  { group: "会社について", links: ["会社案内", "スタッフ紹介", "沿革", "採用情報"] },
  { group: "サービス", links: ["制作メニュー", "料金の目安", "制作実績", "よくある質問"] },
];

/** 帯03で足される行のひな形(順に使い、5件目以降は先頭へ戻る) */
const rowSeeds = [
  { name: "会社案内ページの下書き", meta: "未公開" },
  { name: "料金表の改定メモ", meta: "下書き" },
  { name: "採用ページの構成案", meta: "未公開" },
  { name: "お知らせの定型文", meta: "下書き" },
];

/** 帯の見出し(左に角の無いラベル、右に「行動 2」の上限表示) */
function BandHead({
  label,
  title,
  note,
}: {
  label: string;
  title: string;
  note: string;
}) {
  return (
    <div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
      <span className="inline-flex items-center border border-gray-900 px-2 py-[3px] text-[11px] leading-none font-semibold text-gray-900">
        {label}
      </span>
      <h3 className="text-[17px] leading-[1.6] font-bold text-gray-900">{title}</h3>
      <span
        className={`${montserrat.className} ml-auto text-[11px] font-semibold text-gray-400 tabular-nums`}
      >
        {note}
      </span>
    </div>
  );
}

/** 面を持つ第一行動(角丸なし・アンバー) */
function PrimaryAction({
  children,
  onClick,
  disabled,
}: {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className="inline-flex items-center justify-center bg-amber-600 px-5 py-2.5 text-[13px] font-semibold text-white transition-colors duration-200 ease-out hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:bg-gray-200 disabled:text-gray-400 motion-reduce:transition-none"
    >
      {children}
    </button>
  );
}

/** 第二行動は面を持たせない(下線ワイプのみ・矢印は置かない) */
function SecondaryAction({
  children,
  onClick,
  href,
}: {
  children: React.ReactNode;
  onClick?: () => void;
  href?: string;
}) {
  const inner = (
    <span className="relative">
      {children}
      <span
        aria-hidden="true"
        className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-300 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>
  );
  const cls =
    "group inline-flex items-center px-1 py-2.5 text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600";
  if (href) {
    return (
      <a href={href} className={cls}>
        {inner}
      </a>
    );
  }
  return (
    <button type="button" onClick={onClick} className={cls}>
      {inner}
    </button>
  );
}

export default function EmptyAndErrorStates() {
  const [query, setQuery] = useState("問い合わせフォームの離脱率");
  const [count, setCount] = useState(0);

  const q = query.trim();
  const hits = useMemo(
    () => (q === "" ? docs : docs.filter((d) => d.title.includes(q) || d.tag.includes(q))),
    [q],
  );

  return (
    <section
      aria-labelledby="empty-states-heading"
      className="w-full max-w-5xl bg-white px-1"
    >
      {/* ヘッダー(英字キッカーを置かず、標本IDは見出し行の右端へ逃がす) */}
      <header>
        <div className="flex items-baseline justify-between gap-4 border-b border-gray-900 pb-3">
          <h2
            id="empty-states-heading"
            className="text-[clamp(1.55rem,3.4vw,2.2rem)] leading-[1.25] font-bold tracking-tight text-gray-900"
          >
            何も無い画面
          </h2>
          <span
            className={`${montserrat.className} text-[11px] font-semibold text-gray-400 tabular-nums`}
          >
            BLK-73
          </span>
        </div>
        <p className="mt-5 max-w-2xl text-[13.5px] leading-[1.95] font-medium text-gray-600">
          見つからない・0件・まだ無いの3つを、切り替えずに並べて置いた台帳です。謝る文章を主役にせず、そこから取れる行動を2つだけ残しています。下の2帯は実際に動きます。
        </p>
      </header>

      {/* 帯01: 404(離脱の受け皿として主要導線を出す) */}
      <div className="mt-10 border-t border-gray-200 pt-8">
        <BandHead label="404" title="ページが見つからない" note="行動 2 + 主要導線" />
        <div className="mt-6 grid grid-cols-1 gap-x-10 gap-y-8 md:grid-cols-12">
          <div className="md:col-span-5">
            <p
              className={`${montserrat.className} text-[clamp(3.2rem,9vw,4.6rem)] leading-[0.9] font-bold tracking-tight text-gray-900 tabular-nums`}
            >
              404
            </p>
            <span aria-hidden="true" className="mt-4 block h-px w-10 bg-amber-600" />
            <p className="mt-5 text-[15px] leading-[1.8] font-bold text-gray-900">
              このアドレスのページはありません
            </p>
            <p className="mt-2 text-[13px] leading-[1.95] text-gray-600">
              移転したか、公開を終えた可能性があります。行き先は右の一覧からも選べます。
            </p>
            <div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-1">
              <PrimaryAction>トップページへ戻る</PrimaryAction>
              <SecondaryAction href="#">サイト内を検索する</SecondaryAction>
            </div>
          </div>

          <nav aria-label="主要な行き先" className="md:col-span-7">
            <div className="grid grid-cols-1 gap-x-8 sm:grid-cols-2">
              {directory.map((d) => (
                <div key={d.group}>
                  <p className="pb-2 text-[11px] font-semibold tracking-[0.04em] text-gray-400">
                    {d.group}
                  </p>
                  <ul className="list-none pl-0">
                    {d.links.map((link) => (
                      <li key={link} className="border-t border-gray-200 last:border-b">
                        <a
                          href="#"
                          className="group flex items-center py-2.5 text-[13.5px] font-medium text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                        >
                          <span
                            aria-hidden="true"
                            className="mr-3 h-px w-3 bg-gray-300 transition-[width,background-color] duration-300 ease-out group-hover:w-5 group-hover:bg-amber-600 motion-reduce:transition-none"
                          />
                          {link}
                        </a>
                      </li>
                    ))}
                  </ul>
                </div>
              ))}
            </div>
          </nav>
        </div>
      </div>

      {/* 帯02: 検索結果0件(語を削る操作でヒットへ戻せる) */}
      <div className="mt-12 border-t border-gray-200 pt-8">
        <BandHead label="0件" title="検索結果がない" note="行動 2" />

        <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-stretch">
          <label className="flex-1">
            <span className="sr-only">記事を検索</span>
            <input
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              placeholder="キーワードで探す"
              className="w-full border border-gray-300 bg-white px-4 py-2.5 text-[14px] text-gray-900 placeholder:text-gray-400 focus-visible:border-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
            />
          </label>
          <p
            className={`${montserrat.className} flex items-center text-[12px] font-semibold text-gray-400 tabular-nums`}
            aria-live="polite"
          >
            {hits.length} / {docs.length} 件
          </p>
        </div>

        <div className="mt-5 min-h-[13rem]">
          {hits.length > 0 ? (
            <ul key="hits" className="blk73-swap list-none pl-0">
              {hits.map((d) => (
                <li key={d.title} className="border-t border-gray-200 last:border-b">
                  <a
                    href="#"
                    className="group grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-4 py-3.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                  >
                    <span className="border border-gray-200 px-2 py-[3px] text-center text-[11px] leading-none font-semibold text-gray-500">
                      {d.tag}
                    </span>
                    <span className="relative justify-self-start text-[14px] leading-[1.7] font-medium text-gray-900">
                      {d.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>
                  </a>
                </li>
              ))}
            </ul>
          ) : (
            <div
              key="none"
              role="status"
              aria-live="polite"
              className="blk73-swap border-y border-gray-200 py-8"
            >
              <p className="text-[15px] leading-[1.8] font-bold break-all text-gray-900">
                「{q}」に一致する記事はありません
              </p>
              <p className="mt-2 max-w-xl text-[13px] leading-[1.95] text-gray-600">
                語が長いほど一致しにくくなります。まずは末尾を削るか、条件を外して全件から探してください。
              </p>
              <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-1">
                <PrimaryAction
                  onClick={() => setQuery(q.slice(0, -1))}
                  disabled={q.length === 0}
                >
                  末尾を1文字削る
                </PrimaryAction>
                <SecondaryAction onClick={() => setQuery("")}>
                  条件を外して全件を見る
                </SecondaryAction>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* 帯03: 一覧が空(これから入る行の形をゴーストで見せる) */}
      <div className="mt-12 border-t border-gray-200 pt-8">
        <BandHead label="空" title="一覧にまだ1件も無い" note="行動 2" />
        <div className="mt-6 grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-12">
          <div className="md:col-span-5">
            {count === 0 ? (
              <div role="status" aria-live="polite">
                <p className="text-[15px] leading-[1.8] font-bold text-gray-900">
                  表示できる項目はまだありません
                </p>
                <p className="mt-2 text-[13px] leading-[1.95] text-gray-600">
                  最初の1件を作ると、右の枠と同じ形で並びます。中身を見てから決めたいときはサンプルを読み込んでください。
                </p>
                <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-1">
                  <PrimaryAction onClick={() => setCount(1)}>最初の1件を作る</PrimaryAction>
                  <SecondaryAction onClick={() => setCount(3)}>
                    サンプルを読み込む
                  </SecondaryAction>
                </div>
              </div>
            ) : (
              <div role="status" aria-live="polite">
                <p className="text-[15px] leading-[1.8] font-bold text-gray-900">
                  {count}件を下書きとして保存しました
                </p>
                <p className="mt-2 text-[13px] leading-[1.95] text-gray-600">
                  空の状態に戻すと、行の形だけを示す枠がふたたび現れます。見本なので保存先はありません。
                </p>
                <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-1">
                  <PrimaryAction onClick={() => setCount(count + 1)}>
                    もう1件作る
                  </PrimaryAction>
                  <SecondaryAction onClick={() => setCount(0)}>空の状態に戻す</SecondaryAction>
                </div>
              </div>
            )}
          </div>

          <div className="md:col-span-7">
            {count === 0 ? (
              <ul key="ghost" className="blk73-swap list-none pl-0" aria-hidden="true">
                {[0, 1, 2].map((i) => (
                  <li
                    key={i}
                    className="mt-2 border border-dashed border-gray-300 px-4 py-4 first:mt-0"
                  >
                    <span
                      className="block h-2 bg-gray-200"
                      style={{ width: `${62 - i * 8}%` }}
                    />
                    <span
                      className="mt-2 block h-2 bg-gray-100"
                      style={{ width: `${38 - i * 5}%` }}
                    />
                  </li>
                ))}
              </ul>
            ) : (
              <ul className="list-none pl-0">
                {Array.from({ length: count }, (_, i) => {
                  const seed = rowSeeds[i % rowSeeds.length];
                  return (
                    <li
                      key={i}
                      className="blk73-swap flex items-baseline justify-between gap-4 border-t border-gray-200 py-3.5 last:border-b"
                      style={{ animationDelay: `${i * 45}ms` }}
                    >
                      <span className="text-[14px] leading-[1.7] font-medium text-gray-900">
                        {seed.name}
                      </span>
                      <span className="shrink-0 border border-gray-200 px-2 py-[3px] text-[11px] leading-none font-semibold text-gray-500">
                        {seed.meta}
                      </span>
                    </li>
                  );
                })}
              </ul>
            )}
          </div>
        </div>
      </div>

      <style>{`
        @keyframes blk73-swap {
          from { opacity: 0; transform: translateY(3px); }
          to { opacity: 1; transform: none; }
        }
        .blk73-swap { animation: blk73-swap 260ms ease-out backwards; }
        @media (prefers-reduced-motion: reduce) {
          .blk73-swap { animation: none !important; }
        }
      `}</style>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/empty-and-error-states.json