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

Shota Yamashita

Shota Yamashita

Archaive事業部 プロダクトマネージャー

こんにちは。STARUPで製造業向けのプロダクト「ARCHAIVE」のプロダクトマネージャーをしている山下です。今はPdMですが、もともとはデザイナー兼フロントエンドエンジニアで、UIを作るのが好きです。

この記事は、UIをもう少しリッチにしたいけれど、どうすればいいか迷っている人に向けて書いています。

画面を作っていると、「ここ、なんだか寂しいな」という余白によく出会います。データがまだ無いページ、404、エラー画面。よく見るのは、アイコンをひとつ置いて「データが登録されていません」と一言添える形です。

よく見る静的なEmptyState よく見る、静的なEmptyState。悪くはないけれど、もう一捻りしたい。

悪くはないんですが、もう一歩、何かできないかとずっと思っていました。そんなとき、claude-designで遊んでいて「その余白に、小さなアニメーションを置いてみたらどうだろう」と思いつき、実際に試してみました。ここでは、ARCHAIVEで実際に使っている2つを紹介します。

実例①:操作の結果を予告する

列が追加されるアニメーション 「設定ページへどうぞ」と案内する画面。項目を追加すると列が生えることを、小さく見せている。

これは、項目(列)の設定を「設定ページでやってください」と案内する場所で使っているアニメーションです。ただ文字で案内するのではなく、設定するとどうなるか——テーブルに新しい列が生えてくる様子——を小さく再生しています。設定ページへ移る前に結果のイメージが湧くので、「この設定をすると何が変わるんだろう」という迷いが減ります。動きの最後をちょっとだけ弾ませていて、これが地味に気持ちいいんです。

実例②:状態を見せる

データ取得失敗のアニメーション 雲(データソース)から読み込み → 失敗して落雷 → 赤くなって静止する。

もうひとつは、データの取得に失敗したときのエラー表示。雲からデータが流れてきて、途中で失敗して落雷が落ちる。ただ「取得に失敗しました」と文字で出すより、"何が起きたのか"がなんとなく伝わります。…とはいえ、これはエラーが出たときにしか出てこないので、存在を知らない人も多いかもしれません(笑)。

どこに効くのか

今回はエラーと設定でしたが、同じ発想が効く余白は他にもあります。データが0件のEmptyState、404、まだ何もないダッシュボード、「まず登録しましょう」と誘導ボタンだけを置くページ。スペースはあるけど、特に置くものがない場所。ここに小さなアニメーションがあると、ちょっと可愛いし、状況も一目で伝わります。

やってみて

良かったのは、まず単純に分かりやすくなったこと。それから、画面に少し親しみが出たことです。硬い業務システムでも、こういう小さな動きがあると、ふっと空気がやわらぎます。あと正直に言うと、作っているこちら側が楽しい。

一方で、気をつけることもあります。あちこちに入れると、うるさくなってすぐ飽きられる。だから「余白があって、かつ意味が伝わる」場所に絞る。エラー系は控えめに、機能説明系は軽くループ、と使い分ける。このさじ加減は、まだ手探りです。

どうやって量産しているか

作り方は、だいたいこの流れです。

  1. まず方向性の違うパターンを、HTMLで5〜6個ざっと作って見比べる
  2. いい方向性を1つ選んで、その方向でさらに10個くらい作り込む
  3. 一番よかったものを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あたりから試してみると、手応えがつかめると思います。

Shota Yamashita

Shota Yamashita

Archaive事業部 プロダクトマネージャー

// we are hiring

STARUPで一緒に働きませんか?

こうした技術的な挑戦を一緒に楽しめる仲間を募集しています。

採用情報を見る →