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帯は実際に動きます。
ページが見つからない
行動 2 + 主要導線検索結果がない
行動 20 / 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