余白に、小さなアニメーションを ― 404やEmptyStateを一捻りする

Shota Yamashita
Archaive事業部 プロダクトマネージャー
こんにちは。STARUPで製造業向けのプロダクト「ARCHAIVE」のプロダクトマネージャーをしている山下です。今はPdMですが、もともとはデザイナー兼フロントエンドエンジニアで、UIを作るのが好きです。
この記事は、UIをもう少しリッチにしたいけれど、どうすればいいか迷っている人に向けて書いています。
画面を作っていると、「ここ、なんだか寂しいな」という余白によく出会います。データがまだ無いページ、404、エラー画面。よく見るのは、アイコンをひとつ置いて「データが登録されていません」と一言添える形です。
よく見る、静的なEmptyState。悪くはないけれど、もう一捻りしたい。
悪くはないんですが、もう一歩、何かできないかとずっと思っていました。そんなとき、claude-designで遊んでいて「その余白に、小さなアニメーションを置いてみたらどうだろう」と思いつき、実際に試してみました。ここでは、ARCHAIVEで実際に使っている2つを紹介します。
実例①:操作の結果を予告する
「設定ページへどうぞ」と案内する画面。項目を追加すると列が生えることを、小さく見せている。
これは、項目(列)の設定を「設定ページでやってください」と案内する場所で使っているアニメーションです。ただ文字で案内するのではなく、設定するとどうなるか——テーブルに新しい列が生えてくる様子——を小さく再生しています。設定ページへ移る前に結果のイメージが湧くので、「この設定をすると何が変わるんだろう」という迷いが減ります。動きの最後をちょっとだけ弾ませていて、これが地味に気持ちいいんです。
実例②:状態を見せる
雲(データソース)から読み込み → 失敗して落雷 → 赤くなって静止する。
もうひとつは、データの取得に失敗したときのエラー表示。雲からデータが流れてきて、途中で失敗して落雷が落ちる。ただ「取得に失敗しました」と文字で出すより、"何が起きたのか"がなんとなく伝わります。…とはいえ、これはエラーが出たときにしか出てこないので、存在を知らない人も多いかもしれません(笑)。
どこに効くのか
今回はエラーと設定でしたが、同じ発想が効く余白は他にもあります。データが0件のEmptyState、404、まだ何もないダッシュボード、「まず登録しましょう」と誘導ボタンだけを置くページ。スペースはあるけど、特に置くものがない場所。ここに小さなアニメーションがあると、ちょっと可愛いし、状況も一目で伝わります。
やってみて
良かったのは、まず単純に分かりやすくなったこと。それから、画面に少し親しみが出たことです。硬い業務システムでも、こういう小さな動きがあると、ふっと空気がやわらぎます。あと正直に言うと、作っているこちら側が楽しい。
一方で、気をつけることもあります。あちこちに入れると、うるさくなってすぐ飽きられる。だから「余白があって、かつ意味が伝わる」場所に絞る。エラー系は控えめに、機能説明系は軽くループ、と使い分ける。このさじ加減は、まだ手探りです。
どうやって量産しているか
作り方は、だいたいこの流れです。
- まず方向性の違うパターンを、HTMLで5〜6個ざっと作って見比べる
- いい方向性を1つ選んで、その方向でさらに10個くらい作り込む
- 一番よかったものをReactコンポーネントにする
コンポーネントにするときは、色をすべてCSS変数にしてダークモードに追従させ、中身は状態管理を使わずDOMを直接触って軽くしています。置き場所は frontend/src/shared/ui/micro-animation/ にまとめています。なぜ shared/ui なのかは、FSD(Feature-Sliced Design)という設計の考え方に沿っているからで、詳しくは木村が書いたFSDの記事をどうぞ。
そして、この一連の手順はClaudeに「スキル」として渡してあって、「〇〇のアニメを作って」と頼むと、この流れで作ってくれます。おかげで、思いついたら5分で1個くらいのペースで増やせています。
SKILL.claude/skills/micro-animation/SKILL.md中身を見る →
name: micro-animation
description: 余白(EmptyState・エラー表示・機能の案内)に置く小さなアニメーションを作る。HTMLで方向性の違う試作を並べて選び、選んだものをReactコンポーネントに仕上げる。
allowed-tools: Read, Write, Edit, Bash(mkdir:*), Bash(mv:*), Bash(rm:*), Bash(open:*)
データが0件のEmptyState、エラー表示、「設定は設定ページで」といった案内。 置くものがない余白に、状況が一目で伝わる小さなアニメーションを作る。
配置
| 種別 | 場所 |
|---|---|
| Reactコンポーネント(実装) | src/shared/ui/micro-animation/<ComponentName>.tsx |
| HTML試作(作業用。最後に消す) | public/html/micro-animation/<name>.html |
作る手順
Step 1 方向性の違う試作を5〜6個並べる
いきなり1つに絞らない。同じ題材で、見せ方の方向性が違うものを5〜6個、 素のHTML + CSS + JSで作る。1ファイル1案にして、まとめてブラウザで開いて見比べる。
方向性の例です。同じ「データ取得の失敗」でも、これだけ変わります。
- 何が起きたかを図で語る(雲からデータが来る、途中で落ちる)
- 状態の変化だけを見せる(色と形が変わる)
- 操作の結果を予告する(設定するとこうなる、を再生する)
書き終えたら open でブラウザを開く。開発サーバは不要。
open "file://$PWD/public/html/micro-animation/<name>.html"
試作のルール
body { background: transparent; }にする(埋め込み先の背景に合わせるため)- 操作なしで再生が始まる(クリックさせない)
- ループは
async/awaitと再帰setTimeoutで直列に組む。setIntervalとsetTimeoutを混ぜると再生が競合するので使わない
async function loop() {
while (running) {
clearAll();
await open(); // 登場
await hold(); // キープ
await close(); // 退場
await gap(); // 間隔
}
}
setTimeout(loop, 400);
Step 2 選んだ方向で作り込む
5〜6個から良い方向性を1つ選び、その方向のまま10個ほど作る。 変えるのはタイミング、オーバーシュートの量、余白、色の当て方といった細部で、 方向性そのものは変えない。ここで詰めるほど完成度が上がる。
Step 3 Reactコンポーネントにする
一番よかった1つをコンポーネントに移す。
iframeは使わない。 CSPがインラインスクリプトを止めるため、HTMLをそのまま埋め込む形にはできない。 マークアップとキーフレームをコンポーネントの中へ移植する。
状態管理を使わず、DOMを直接触る。 useState でアニメーションの状態を持つと、
フレームごとに再レンダリングが走る。useRef から直接スタイルを当てれば再レンダリングは起きない。
'use client';
import { useEffect, useRef } from 'react';
export function XxxAnimation() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let running = true;
async function loop() {
while (running) {
el.style.animation = 'none';
void el.offsetWidth; // reflowを挟んでアニメーションを巻き戻す
el.style.animation = 'xxx 0.5s ease-out forwards';
await new Promise((r) => setTimeout(r, 500));
}
}
const t = setTimeout(loop, 400);
return () => {
running = false;
clearTimeout(t);
};
}, []);
return (
<>
<style>{`
@keyframes xxx { from { /* ... */ } to { /* ... */ } }
`}</style>
<div ref={ref}>{/* ... */}</div>
</>
);
}
Step 4 試作HTMLを消す
組み込みまで終わったら、public/html/micro-animation/ に置いた試作を削除する。
public/ に作業中のものを残さない。
決めごと
色はCSS変数だけを使う
ダークモードに追従させるため、#rrggbb やHSL値を直接書かない。
まず定義済みの変数から選び、足りないときだけライトとダークの両方に追加してから使う。
SVGの fill と stroke も同じく変数で指定する。
| 用途 | 指定 |
|---|---|
| アクセント、アウトライン | var(--primary) |
| アクセント上のテキスト | var(--primary-foreground) |
| 薄いアクセント背景 | var(--filter-chip-bg) |
| 罫線、区切り | var(--gray-200) |
| カード背景 | var(--card) |
| ヘッダー背景 | var(--gray-100) |
| テキスト | var(--gray-500) |
| エラー(背景/線/濃色) | var(--red-50) / var(--red-500) / var(--red-700) |
動きの付け方
- 登場: 軽くオーバーシュートさせて弾ませる。行き過ぎてから戻る
- 退場: フェードアウトしてから縮める。凝らない
- ループ間隔: 表示1〜1.5秒、間隔0.5〜0.8秒
ループさせるものと、させないもの
機能の案内(設定するとこうなる、の予告など)はループさせる。 何度でも再生されて構わないし、見逃しても次が来る。
状態の提示(取得に失敗した、など)はループさせない。 一度だけ再生して、最終状態で静止させる。点滅や強い揺れは不安を与えるので、 状態の提示では動きを控えめにする。
// 一度だけ再生して止める
async function play() {
await open();
if (!running) return;
await fail(); // 最終状態のまま止める。closeもgapも呼ばない
}
setTimeout(play, 350);
型:テーブルに列が増える
行ではなく列を単位にしたflexで組む。行で組むと、列全体にアウトラインをかけられない。
<div className='flex' style={{ background: 'var(--gray-200)', gap: '1px' }}>
<div className='flex flex-col' style={{ gap: '1px' }}>
{/* ヘッダーとデータセル */}
</div>
{/* 増える列。width:0 から伸ばす */}
<div ref={wrapRef} style={{ width: '0px', overflow: 'hidden', flexShrink: 0 }}>
<div
ref={colRef}
className='flex flex-col'
style={{ width: '72px', outline: '2px dashed var(--primary)', outlineOffset: '-1px' }}
>
<div style={{ background: 'var(--filter-chip-bg)', color: 'var(--primary)' }}>
<span className='bg-primary text-primary-foreground'>+</span>
{newColumn}
</div>
<div style={{ background: 'var(--filter-chip-bg)', color: 'var(--primary)', opacity: 0 }}>―</div>
</div>
</div>
</div>
幅を弾ませるキーフレーム。行き過ぎてから戻して、減衰させる。
@keyframes col-width-in {
0% { width: 0px; }
50% { width: 84px; } /* 行き過ぎる */
65% { width: 62px; }
80% { width: 77px; }
90% { width: 68px; }
100% { width: 72px; } /* 最終幅 */
}
セルの高さは固定値で書く。ヘッダーの文字数で高さが変わると、隣の列とずれる。
まとめ
アイコンやイラストを1枚置く代わりに、余白に小さなアニメーションを置いてみる。可愛いし、状況も伝わるし、作るのも楽しい。UIをもう一捻りしたい人は、まず404やEmptyStateあたりから試してみると、手応えがつかめると思います。