shadcn/ui があなたに委ねる状態: ローディング、成功、エラー、空、そして reduced motion
shadcn/ui がくれるのはきれいなボタンで、ローディングボタンではありません。すべての shadcn プロジェクトが手作りしている難しい UI の状態と、それを自然に感じさせるタイミング。
Preet Suthar · · 5分で読めます
- React
- shadcn/ui
- UX
- Accessibility
「shadcn button loading state」で検索すると、同じことをするスニペットが山ほど見つかります。isLoading prop を追加し、ラベルをスピナーに差し替え、ボタンを無効にする。それを出荷すると、アプリは追加する前より悪く感じられます。
これは shadcn/ui への批判ではありません。きれいな出発点を提供し、状態は意図的にあなたに委ねています。しかし「状態」こそがインターフェースの手触りの大半が宿る場所で、そこに時間を割く人はほとんどいません。これは私が出荷するすべてのコンポーネントで対応している状態の一覧と、「動く」と「仕上がっている」を分ける具体的なタイミングです。
1. ちらつかないローディング
素朴なローディングボタンは、クリックした瞬間にスピナーを表示します。ほとんどのリクエストは 200ms 以内に終わるので、ユーザーに見えるのは一瞬のスピナーと、その後の結果です。これはガタつきとして認識されます。
二つのルールで直ります:
- スピナーを表示する前に待つ。 リクエストが 150ms ほどで終わるなら、まったく表示しません。
- 一度表示したら最低限の時間は出しておく。 400ms ほどです。30ms だけ現れるスピナーは、スピナーがないより悪いです。
それは prop ではなくフックです。形はこうです:
track に promise を渡します。フックは、リクエストがスピナーを飛ばせるほど速かったかを判断し、ちらつかない長さだけスピナーを保持し、promise が確定したらボタンを成功かエラーに移します。HextaUI の useButtonFeedback フックと useDelayedLoading はまさにこれを行い、一度で読み切れるくらい小さいです。
同じ遅延のルールはスケルトンにも当てはまります。50ms だけ現れてコンテンツに置き換わるスケルトンは、灰色の一瞬の点滅です。150ms 遅らせ、コンテンツが届いたら、周囲を何も動かさずにフェードインさせます。
2. その場で示す成功とエラー
保存に成功したら、ボタンがそう伝えるべきです。チェックマーク付きの「Saved」を 2 秒ほど表示し、アイドルに戻します。失敗したときもボタンが伝えるべきで、エラーはもっと長く、4 秒ほど保持します。人はエラーを読んで何をするか決めるのに時間が必要だからです。
聞こえ以上に重要な細部が二つあります:
- ポインターがボタンの上にある間はリセットしない。 ホバーしているなら読んでいる最中です。離れてからリセットします。
- リクエストの進行中はクリックを無視する。 ただし
disabledは使いません。ボタンがタブ順序から外れ、スクリーンリーダーに飛ばされてしまいます。aria-busyを使い、クリックを無視します。
Switch にも、より狭い場所で同じ問題があります。切り替えてリクエストを送り、失敗したら元に戻します。ユーザーには巻き戻しが見える必要があるのでアニメーションさせ、保存中は短い保留状態を表示します。
3. 入力中に罰しない無効状態
送信しようとして無効だとわかったときにフィールドを揺らします。まだ入力している最中には決して揺らしません。ルールはトリガーで決まります。送信時や blur 時のバリデーションは揺らし、change 時のバリデーションは揺らしません。
揺れ自体は短くします。300ms ほどで三、四回の振動です。prefers-reduced-motion のもとでは、境界線の色の変化だけになり、それ以外は何もしません。
ここではネイティブの :user-invalid が役立ちます。ユーザーが操作した後にしかマッチしないので、React に touched フラグを持たせずに CSS で無効状態をスタイルできます。
4. 空は不在ではなく状態
空のリストは新規ユーザーが最初にたどり着く場所で、デフォルトは上にヘッダーが付いただけの空白です。すべての空状態には三つのものが必要です。その空間が意図的だと伝わるアイコンかイラスト、ここに何が表示されるかを述べる一文、そして何かを表示させるためのアクションです。
これは作るのが安く、それでいてほぼ必ず飛ばされます。機能を作っている人にはテストデータがあるからです。ときどきデータベースを空にしてみてください。
5. reduced motion は「動きなし」ではない
prefers-reduced-motion: reduce は無視できない割合のユーザーで有効になっていて、手抜きの実装はグローバルな * { transition: none } です。これではモーションが運んでいた情報まで消えてしまいます。フェードなしで一瞬で開くダイアログは、フェードするものより読み取りにくいのです。
私が使っているルールはこうです。画面上を移動するものはフェードにする。フェードするものはフェードのまま。ループするものは止める。時間は短くするが、ゼロにはしない。
判定は一か所にまとめ、すべてのコンポーネントがそれを読むようにします:
イージングカーブと時間も共有してください。コンポーネントごとに独自の cubic-bezier を選んでいると、アプリは 6 人で作られたように感じられます。実際そうだからです。
6. 中断できるモーション
アコーディオンを開き、開くアニメーションの途中で閉じてみてください。多くの実装では、開き切ってから閉じるか、ジャンプします。正しい挙動は、いまいる位置から逆再生することです。
Web Animations API ならこれは簡単です。実行中のアニメーションへの参照を保持し、次の切り替え時にその現在時刻を読み、現在の算出済みの高さからアニメーションします。height: auto に対する CSS トランジションにはこれができず、grid-template-rows: 0fr から 1fr ならできますが途中で逆再生するのは難しいです。中断できる方法を選んでください。
7. タップ領域と iOS のズーム
ノート PC では決して見えないのに、スマートフォンでは壊れるものが二つあります:
- 16px 未満の入力欄は iOS Safari でページをズームさせる。 テーマが他の場所で 15px でも、粗いポインターでは入力欄のテキストを 16px に保ちます。
@media (pointer: coarse)のルール一つで済みます。 - 44px 未満のタップ領域は外れる。 ボタンはデスクトップでは高さ 32px でよく、同じメディアクエリでタッチスクリーンでは大きくします。
8. 右から左へ
アラビア語やヘブライ語のユーザーに提供することがあるなら、コンポーネント内の ml-、pl-、left- はすべてバグです。論理プロパティの ms-、ps-、start- を使ってください。shadcn/ui は 2026 年 1 月に RTL 対応を追加したので、新しく取り込むコンポーネントは問題ありませんが、2024 年にコピーしたものはそうではありません。
ここからどうするか
どれも難しくはありません。ただ、shadcn/ui ほどの規模のコンポーネントセットで全部やると数週間の作業になり、終わっても目に見えない作業なので、削られてしまいます。
私が HextaUI を作ったのは、プロジェクトのたびにこの作業を繰り返していたからです。shadcn/ui プロジェクトに追加する、無料で MIT ライセンスのレジストリです。同じ CLI、同じコンポーネント名、同じテーマトークンで、上記の状態はすべて、150ms のスピナー遅延に至るまで対応済みです。コードを自分で所有して状態も自分で書きたいなら、フックは単体で使えるので、それだけをインストールすることもできます。
よくある質問
ローディング中はボタンを無効にすべきですか? aria-busy="true" を設定し、クリックを無視します。disabled はボタンをタブ順序から外し、操作の途中でスクリーンリーダーから隠してしまいます。
成功状態はどのくらい表示すべきですか? 2 秒ほどで、ポインターがまだコントロールの上にあればもっと長く。エラーは 4 秒ほどです。
スケルトンは常にスピナーより良いですか? これから来るものの形がわかっているならスケルトンのほうが良いです。アクションにはスピナーのほうが良いです。どちらにも 150ms の遅延が必要です。
reduced motion はアニメーションをすべて取り除くという意味ですか? いいえ。移動はフェードに置き換え、ループは止め、時間は短くします。情報は残します。