GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

エンプティステート デザイン 9選 — CSSだけで描く

エンプティステート デザインは、一覧に出すデータが1件もないときに、その空いた場所を代わりに埋める画面です。ここにある9つのうち1つは、データがないからではなく見る権限がないから空いていて、その違いは南京錠1つで見分けられます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つは見た目の順ではなく、画面が空になる理由の順に並べてあります。最初の3つはユーザーが何も間違えていない場合です。まだ何もない(01)、検索語に合うものがない(02)、入れるべきなのにまだ入れていない(03)。真ん中の3つはユーザーが自分で戻せる場合です。接続が切れた(04)、カートに何も入っていない(05)、フィルタをかけすぎた(06)。最後の3つはシステムが答える番です。アドレスが違う(07)、権限がない(08)、そして09は空であること自体が目標だった画面です。9つとも、ボタンか入力欄が実際に動き、状態が変わる行には aria-live="polite" が付いているので、画面が見えない人にも変わった文が読み上げられます。アイコンは手で描かず、Phosphor Duotone の SVG をそのまま HTML に埋め込んでいるため、エンプティステートの読み込み時の外部リクエストは0件です。空ではなく溢れて折る画面はアコーディオンリスト UIへ、空だった棚が埋まっていく過程を見せる画面はファイルツリー 作成へと続きます。この順だから前半の三つはそれぞれ別の言い訳になり、後半の三つは別の招待状になります。空の理由を三つに分けたのは文面を書く人に「なぜ空か」を選ばせるためで、選んだ理由によって図柄とボタンの役割が変わります。図柄はどれもCSSの図形だけで描いています。

01受信箱ゼロの浮遊カード

エンプティステート とは何が見えるものか

トレイのアイコンが14px浮かんでは沈み、足元の楕円の影が同じリズムで細くなります。

受信箱ゼロの浮遊カード
新しいタブで開く

01 · 受信箱ゼロの浮遊カード — 計測 累積面積 1.28% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateYscaleXaria-live

コードのどこを見るか

影を一緒に縮めるのが浮いて見えるコツで、アイコンだけを動かすと浮遊ではなく上下の揺れに見えてしまいます。

.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つもないときだけ虫眼鏡のカードがその場所を覆います。

なぞって通る検索0件
新しいタブで開く

02 · なぞって通る検索0件 — 計測 累積面積 2.691% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateXrotatehidden

コードの読み方

虫眼鏡は左右に動きながら角度も傾くので、止まっているのではなく「まだ探している」ように見えます。

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フレームで消えます。

ファイルが落ちる破線ドロップゾーン
新しいタブで開く

03 · ファイルが落ちる破線ドロップゾーン — 計測 累積面積 0.929% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateY@keyframes dz-falldragover

どこを変えるか

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まで一方向にだけ回るので、最後のフレームが最初のフレームと同じ見た目になり、巻き戻る瞬間がありません。

再試行の矢印が回るオフライン
新しいタブで開く

04 · 再試行の矢印が回るオフライン — 計測 累積面積 0.574% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotatelineararia-live

コードのどこを見るか

ボタンを実際に押すと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%の地点で止まり、一度傾いて、来た道をそのまま戻っていきます。

転がって入る空のカート
新しいタブで開く

05 · 転がって入る空のカート — 計測 累積面積 2.624% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateXrotatetransform-origin

コードの読み方

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フレームも残りません。

フィルタチップが1つずつ消える0件
新しいタブで開く

06 · フィルタチップが1つずつ消える0件 — 計測 累積面積 4.229% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scaleXsteps(1, end)animation-delay

どこを変えるか

チップの 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と振れ幅を狭めながら揺れます。

針が迷う404カード
新しいタブで開く

07 · 針が迷う404カード — 計測 累積面積 0.709% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotateborder: dashedease-out

コードのどこを見るか

一周させると「見つけた」ように見え、同じ角度で往復させるとメトロノームに見えてしまうので、だんだん小さくなる振れ幅が道に迷う感じに一番近いものでした。

.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まで強まって戻るだけです。

南京錠が一度跳ねる権限ロック
新しいタブで開く

08 · 南京錠が一度跳ねる権限ロック — 計測 累積面積 0.776% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scalefilter: bluraria-live

コードの読み方

「ない」ではなく「隠されている」を絵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ミリ秒ずつずれて上がるので、前の紙片が薄くなるころには次の紙片がもう中ほどまで来ています。

紙吹雪が上がるタスク完了
新しいタブで開く

09 · 紙吹雪が上がるタスク完了 — 計測 累積面積 1.54% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateanimation-delaycustom property

どこを変えるか

横位置と回転角を --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 のイベントハンドラ側に置いてください。無いものを見せるカードに絵を埋める方法は画像 拡大 表示 UIで写真ファイルなしの手段として再利用します。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

エンプティステート ui にイラストは必ず必要ですか

イラストを入れること自体が目的なら、入れないほうがよいです。ここにある9つがアイコンを使うのは飾りではなく、空になった理由を一目で見分けるためです。南京錠と切れた Wi-Fi と空のカートは、文字を読む前からすでに別の状況だと伝えています。空になる理由が1つしかない画面なら、文1行とボタン1つで十分です。空の理由を指で説明する現場向け画面ならタッチパネル 注文 画面のUIがその口調を整えます。

画像ファイルなしでアイコンだけでも足りますか

9つとも画像ファイルは0個です。Phosphor Duotone の SVG を HTML に直接埋め込んでいるので追加のリクエストは発生せず、color の1行だけで舞台に合わせて色が変わります。SVG は 1em のままにして、包む要素の font-size だけを clamp() で調整すれば、スマホの画面でも同じ比率で小さくなります。

空の状態とローディングを同じカードで使ってもよいですか

使わないでください。ローディングは「もうすぐ何か出る」で、空の状態は「出るものがない」です。同じカードにすると、ユーザーは待てばよいのか行動すればよいのか分からなくなります。04番がちょうどその境目で、一覧は空でも原因はデータがないことではなくリクエストの失敗なので、案内文もボタンも別にします。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。