エンプティステート デザイン 9選 — CSSだけで描く
エンプティステート デザインは、一覧に出すデータが1件もないときに、その空いた場所を代わりに埋める画面です。ここにある9つのうち1つは、データがないからではなく見る権限がないから空いていて、その違いは南京錠1つで見分けられます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 受信箱ゼロの浮遊カード
- 02 なぞって通る検索0件
- 03 ファイルが落ちる破線ドロップゾーン
- 04 再試行の矢印が回るオフライン
- 05 転がって入る空のカート
- 06 フィルタチップが1つずつ消える0件
- 07 針が迷う404カード
- 08 南京錠が一度跳ねる権限ロック
- 09 紙吹雪が上がるタスク完了
9つは見た目の順ではなく、画面が空になる理由の順に並べてあります。最初の3つはユーザーが何も間違えていない場合です。まだ何もない(01)、検索語に合うものがない(02)、入れるべきなのにまだ入れていない(03)。真ん中の3つはユーザーが自分で戻せる場合です。接続が切れた(04)、カートに何も入っていない(05)、フィルタをかけすぎた(06)。最後の3つはシステムが答える番です。アドレスが違う(07)、権限がない(08)、そして09は空であること自体が目標だった画面です。9つとも、ボタンか入力欄が実際に動き、状態が変わる行には aria-live="polite" が付いているので、画面が見えない人にも変わった文が読み上げられます。アイコンは手で描かず、Phosphor Duotone の SVG をそのまま HTML に埋め込んでいるため、読み込み時の外部リクエストは0件です。
01受信箱ゼロの浮遊カード
トレイのアイコンが14px浮かんでは沈み、足元の楕円の影が同じリズムで細くなります。影を一緒に縮めるのが浮いて見えるコツで、アイコンだけを動かすと浮遊ではなく上下の揺れに見えてしまいます。
.ez.is-demo .ez__icon { animation: ez-float $duration $easing infinite; }
.ez.is-demo .ez__shadow { animation: ez-cast $duration $easing infinite; }
@keyframes ez-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-14px); }
}
@keyframes ez-cast {
0%, 100% { transform: scaleX(1); opacity: .18; }
50% { transform: scaleX(.68); opacity: .1; }
}
02なぞって通る検索0件
入力欄に文字を打ち込むと下の3行が実際に絞り込まれ、残る行が1つもないときだけ虫眼鏡のカードがその場所を覆います。虫眼鏡は左右に動きながら角度も傾くので、止まっているのではなく「まだ探している」ように見えます。
function render() {
var v = q.value.trim();
var hit = 0;
rows.forEach(function (r) {
var on = !v || r.dataset.name.indexOf(v) >= 0;
r.hidden = !on;
if (on) hit += 1;
});
empty.hidden = hit > 0;
msg.textContent = v ? msg.dataset.hit.replace('{q}', v) : NONE;
}
03ファイルが落ちる破線ドロップゾーン
破線の枠にファイルが落ちて、62%の地点で下の雲が1.1倍にふくらみ、ファイルは70%と70.1%の間の1フレームで消えます。2枚を重ねてゆっくり入れ替えると両方が半透明になったフレームが残ってしまうので、消える瞬間だけはカットで切ります。
@keyframes dz-fall {
0% { transform: translateY(-28px) scale(.82); opacity: 0; }
16% { transform: translateY(-28px) scale(.82); opacity: 1; }
62% { transform: translateY(30px) scale(1); opacity: 1; }
70% { transform: translateY(26px) scale(1); opacity: 1; }
70.1%, 100% { transform: translateY(-28px) scale(.82); opacity: 0; }
}
04再試行の矢印が回るオフライン
矢印は0degから360degまで一方向にだけ回るので、最後のフレームが最初のフレームと同じ見た目になり、巻き戻る瞬間がありません。ボタンを実際に押すと900ミリ秒だけロックされ、試行回数が案内文にたまっていきます。
.of.is-demo .of__arrow,
.of.is-trying .of__arrow { animation: of-turn $duration $easing infinite; }
@keyframes of-turn {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
05転がって入る空のカート
カートは左端の74px外側から転がって入り、38%の地点で止まり、一度傾いて、来た道をそのまま戻っていきます。transform-origin を車輪の高さである 50% 78% に指定すると、かごではなく車輪を軸にして傾きます。
.ct__cart { transform-origin: 50% 78%; }
.ct.is-demo .ct__cart { animation: ct-roll $duration $easing infinite; }
@keyframes ct-roll {
0%, 100% { transform: translateX(-74px) rotate(-14deg); }
38% { transform: translateX(0) rotate(0deg); }
52% { transform: translateX(0) rotate(-9deg); }
66% { transform: translateX(0) rotate(0deg); }
}
06フィルタチップが1つずつ消える0件
チップ3つが180ミリ秒ずつずれて消え、steps(1, end) なので途中の大きさが1フレームも残りません。チップの x を実際に押すとその条件だけが外れ、残った条件の数が数え直されます。
.fc.is-demo .fc__chip { animation: fc-off $duration steps(1, end) infinite; }
.fc.is-demo li:nth-child(2) .fc__chip { animation-delay: 180ms; }
.fc.is-demo li:nth-child(3) .fc__chip { animation-delay: 360ms; }
@keyframes fc-off {
0%, 32% { opacity: 1; transform: scaleX(1); }
33%, 74% { opacity: 0; transform: scaleX(.2); }
75%, 100% { opacity: 1; transform: scaleX(1); }
}
07針が迷う404カード
コンパスは-26deg、22deg、-14deg、10degと振れ幅を狭めながら揺れます。一周させると「見つけた」ように見え、同じ角度で往復させるとメトロノームに見えてしまうので、だんだん小さくなる振れ幅が道に迷う感じに一番近いものでした。
.nf.is-demo .nf__icon { animation: nf-wander $duration $easing infinite; }
@keyframes nf-wander {
0%, 100% { transform: rotate(-26deg); }
28% { transform: rotate(22deg); }
54% { transform: rotate(-14deg); }
78% { transform: rotate(10deg); }
}
08南京錠が一度跳ねる権限ロック
後ろの行は消さず、ぼかしが3pxから6pxまで強まって戻るだけです。「ない」ではなく「隠されている」を絵1枚で伝える方法で、行の幅を78%と56%のように変えておかないと文章ではなく棒に見えてしまいます。
.pm__rows { filter: blur(3px); }
.pm.is-demo .pm__rows { animation: pm-veil $duration ease-in-out infinite; }
@keyframes pm-veil {
0%, 100% { filter: blur(3px); opacity: 1; }
50% { filter: blur(6px); opacity: .6; }
}
09紙吹雪が上がるタスク完了
紙片6枚が240ミリ秒ずつずれて上がるので、前の紙片が薄くなるころには次の紙片がもう中ほどまで来ています。横位置と回転角を --x・--r の2つの変数で紙片ごとに変えれば、キーフレームは1つのままで6通りに散ります。
.ad.is-demo .ad__bit:nth-child(1) { animation-delay: 0ms; --x: -46px; --r: 180deg; }
.ad.is-demo .ad__bit:nth-child(2) { animation-delay: 240ms; --x: -26px; --r: -150deg; }
@keyframes ad-pop {
0% { transform: translate(var(--x, 0), 0) rotate(0deg) scale(.6); opacity: 0; }
16% { opacity: 1; }
70% { opacity: 1; }
100% { transform: translate(var(--x, 0), -58px) rotate(var(--r, 180deg)) scale(1); opacity: 0; }
}
どこで壊れるか — 落とし穴
空の状態のカードは、たいてい一覧があった場所にそのまま入ります。つまり行を隠すコードとカードを出すコードが同じ瞬間に動くということです。02番で引っかかったのがまさにそこでした。スクリプトは r.hidden = !on で合わない行をきちんと消していたのに、スタイルシートには .sw__row { display: flex; } が書いてありました。ブラウザが [hidden] に与える display: none は UA スタイルシートにある値なので、自分で書いた宣言1行に負けます。結果は、3行が画面に残ったまま、その上に「該当する文書がありません」のカードが半透明で重なった状態でした。レンダリングの測定値は通っていました。動き自体は正常だったからです。最初のポスターを実際に開いてみて、はじめて分かりました。直しは .sw__row[hidden] { display: none; } の1行で、display を指定する部品ごとに同じ行が必要です。
2つ目は自動再生のループを自分の手で止めてしまう失敗です。02番の render() の中に card.classList.remove('is-demo') が入っていて、この関数は初期化のときにも一度呼ばれていました。誰も何も触っていないのにグリッド用のループが止まっていて、測定値は累積面積0.00%・強度0.0・アニメーション0個でした。ループを止める行は render() の中ではなく input のイベントハンドラ側に置いてください。9つ分の完成ソースと React 版は、圧縮パスワード ydp2zjcw を入れると開く zip に入っていて、この2行もすでに直した状態で入っています。
| 症状 | 原因 | 直し方 |
|---|---|---|
| 絞り込んだ行が消えない | .row { display: flex } が UA の [hidden] に勝つ |
.row[hidden] { display: none } を一緒に書く |
| グリッドで動かない | 初期化の関数が .is-demo を外している |
ループ解除はイベントハンドラの中だけに |
| 状態が変わったことが伝わらない | 文だけ入れ替えている | その行に aria-live="polite" を付ける |
アクセシビリティ
prefers-reduced-motion: reduce が有効な画面では9つのループがすべて止まりますが、カードが伝えたかった内容は1つも消えません。05番はカートを舞台の外に置き去りにせず transform: translateX(0) で中央に固定し、08番はぼかしを3px固定で残して「隠されている」という事実だけを伝えます。状態が変わる案内文には aria-live="polite" を付けてあるので、再試行ボタンを押したあとに「接続を確認中」へ変わった文は、画面を見ていない人にも読み上げられます。aria-live の3つの値がそれぞれいつ読み上げられるかは MDN の aria-live のページにまとまっています。03番のドロップゾーンには role="button" と tabindex="0" を一緒に付けてあるため、マウスでドラッグしない人も Enter と Space でファイル選択ダイアログを開けます。
同じ場所で一緒に使うローディング表示はローディングカテゴリに、管理画面の部品はダッシュボードカテゴリにまとめてあります。
FAQ
空の状態にイラストは必ず必要ですか
イラストを入れること自体が目的なら、入れないほうがよいです。ここにある9つがアイコンを使うのは飾りではなく、空になった理由を一目で見分けるためです。南京錠と切れた Wi-Fi と空のカートは、文字を読む前からすでに別の状況だと伝えています。空になる理由が1つしかない画面なら、文1行とボタン1つで十分です。
画像ファイルなしでアイコンだけでも足りますか
9つとも画像ファイルは0個です。Phosphor Duotone の SVG を HTML に直接埋め込んでいるので追加のリクエストは発生せず、color の1行だけで舞台に合わせて色が変わります。SVG は 1em のままにして、包む要素の font-size だけを clamp() で調整すれば、スマホの画面でも同じ比率で小さくなります。
空の状態とローディングを同じカードで使ってもよいですか
使わないでください。ローディングは「もうすぐ何か出る」で、空の状態は「出るものがない」です。同じカードにすると、ユーザーは待てばよいのか行動すればよいのか分からなくなります。04番がちょうどその境目で、一覧は空でも原因はデータがないことではなくリクエストの失敗なので、案内文もボタンも別にします。