リリースしたばかりの Rork アプリをユーザーテストに出したとき、一番よくもらうフィードバックが「起動したあとの数秒が、なんとなく不安」という感想です。処理自体はきちんと動いているのに、真っ白な画面の真ん中でスピナーが回っているだけだと、ユーザーには「止まっているのでは?」「通信がおかしいのでは?」と映ってしまうのです。
この「体感の待ち時間」を短く見せる定番のテクニックがスケルトンスクリーンです。実測の読み込み時間は変わらないのに、離脱率が目に見えて下がる — そんな効果を出してくれる、地味ですが実用性の高い UI パターンです。ここではRork で作ったアプリ(React Native / Expo)に無理なく組み込める実装パターンを、私が実際に使い分けている形でまとめます。
なぜ ActivityIndicator だけでは足りないのか
Rork が自動生成するコードには、デフォルトで ActivityIndicator(いわゆるスピナー)が入っていることが多いです。これは何もないよりは遥かに良いのですが、次のような場面では体感が悪くなります。
- ヒーロー画像が大きい画面(ホームやプロフィール)
- リスト表示で 10 件以上のセルが同時に現れる画面
- タブ切り替えや画面遷移の直後
スピナーは「処理中」以上の情報を伝えません。一方、スケルトンスクリーンは「あとここに画像が出て、ここにタイトルが入るんだな」というこれから現れる情報の予告を兼ねています。Facebook や YouTube が初期ロードをスケルトンで見せているのは、これが理由です。
私は Rork で案件を作るとき、1 秒以上待つ可能性がある画面は全部スケルトンに置き換えるようにしています。ユーザーテストの体感で、明らかに「サクサク感」が変わるからです。
もっとも小さなスケルトンを書いてみる
最小構成を見てもらうのが早いので、まずは静的なスケルトンから始めます。グレーのブロックを並べるだけです。
import { View, StyleSheet } from 'react-native';
// プレースホルダーの最小単位
export function SkeletonBlock({ width, height, radius = 8 }: {
width: number | string;
height: number;
radius?: number;
}) {
return (
<View
style={[
styles.block,
{ width, height, borderRadius: radius },
]}
/>
);
}
const styles = StyleSheet.create({
block: {
backgroundColor: '#e5e7eb', // Tailwind でいう gray-200 相当
},
});これを使ってプロフィールカードのスケルトンを組み立てると、次のようになります。
import { View, StyleSheet } from 'react-native';
import { SkeletonBlock } from './SkeletonBlock';
export function ProfileSkeleton() {
return (
<View style={styles.card}>
<SkeletonBlock width={64} height={64} radius={32} />
<View style={styles.body}>
<SkeletonBlock width="70%" height={16} />
<View style={{ height: 8 }} />
<SkeletonBlock width="40%" height={12} />
</View>
</View>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', padding: 16, alignItems: 'center' },
body: { flex: 1, marginLeft: 12 },
});この段階で、真っ白な画面が「コンテンツのレイアウト予告」に変わります。動きがなくても、スピナーよりは確実にマシです。まずはここまで入れるだけでも、体感は変わります。
Shimmer エフェクトで「生きている感」を足す
静的なスケルトンに満足できなくなったら、次のステップは Shimmer(ハイライトが左から右に流れるアニメーション)です。React Native Reanimated 3 を使うと、JS スレッドに負担をかけずに実装できます。
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
// 流れるハイライト付きスケルトン
export function ShimmerBlock({ width, height, radius = 8 }: {
width: number;
height: number;
radius?: number;
}) {
const translateX = useSharedValue(-width);
useEffect(() => {
// -width 〜 +width を 1.2 秒でループ
translateX.value = withRepeat(
withTiming(width, { duration: 1200, easing: Easing.linear }),
-1,
false,
);
}, [width]);
const style = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<View style={[styles.container, { width, height, borderRadius: radius }]}>
<Animated.View style={[StyleSheet.absoluteFill, style]}>
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.6)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#e5e7eb',
overflow: 'hidden', // 内側のグラデーションを角丸で切り抜く
},
});ポイントは 3 つです。useSharedValue と withRepeat でアニメーションを UI スレッドに逃がすこと、LinearGradient の両端を transparent にして光が滑らかに抜けること、overflow: 'hidden' で角丸の内側にハイライトを閉じ込めること。この辺りを外すと、角がチラついたり、カクついた動きになったりします。
Reanimated の詳しい使い方はRork Reanimated 3 とジェスチャーハンドラーで作る高度なアニメーションで別途まとめています。Shimmer 以外のマイクロインタラクションを足したくなったら、合わせて読んでみてください。
リストのスケルトンは「実際に出す件数」を意識する
一覧画面のスケルトンを作るとき、私が最初の頃によくやっていた失敗が、スケルトンを 3 件しか出さないことでした。画面に 7 件見えるリストで 3 件分しかプレースホルダーを出さないと、実データに切り替わった瞬間に画面が一気に下に伸びて、ユーザーの視線が迷子になります。
実装の指針として、以下を意識すると安定します。
- 画面に見える最大件数(目安:
flatListHeight / itemHeightより少し多め)をスケルトンで出す - セルの高さは、実データと厳密に揃える
- リストのトップから連続して並べ、間隔も本物と同じにする
import { FlatList, View } from 'react-native';
import { SkeletonBlock } from './SkeletonBlock';
const SKELETON_ITEMS = Array.from({ length: 8 }); // 画面に収まる最大件数 + 余裕
export function ListSkeleton() {
return (
<FlatList
data={SKELETON_ITEMS}
keyExtractor={(_, i) => `sk-${i}`}
renderItem={() => (
<View style={{ padding: 16, flexDirection: 'row', alignItems: 'center' }}>
<SkeletonBlock width={48} height={48} radius={24} />
<View style={{ flex: 1, marginLeft: 12 }}>
<SkeletonBlock width="80%" height={14} />
<View style={{ height: 6 }} />
<SkeletonBlock width="50%" height={12} />
</View>
</View>
)}
scrollEnabled={false}
/>
);
}scrollEnabled={false} にしているのは、読み込み中にユーザーが誤ってスクロールすると、実データ到着時の描画コストが跳ね上がるからです。この 1 行だけでも、古めの Android 実機で明らかに差が出ます。
切り替えのタイミングで気を付けていること
スケルトンから実データへの切り替えは、油断するとちらつきます。私がよく入れている対策は次の 2 つです。
1. 最低表示時間を設ける
読み込みが速すぎる場合にスケルトンが一瞬だけ出てしまうと、逆に「画面が点滅した」ように見えます。最低 200 〜 300 ミリ秒は出す、という下駄を履かせることで、安定感が出ます。
import { useEffect, useState } from 'react';
export function useDelayedReady(ready: boolean, minMs = 250) {
const [show, setShow] = useState(ready);
useEffect(() => {
if (!ready) {
setShow(false);
return;
}
const id = setTimeout(() => setShow(true), minMs);
return () => clearTimeout(id);
}, [ready, minMs]);
return show;
}実際の呼び出しはこんな形です。
const { data, isLoading } = useProfile();
const ready = useDelayedReady(!isLoading);
return ready ? <Profile data={data} /> : <ProfileSkeleton />;2. フェードイン 1 枚分の余裕を入れる
スケルトンを消して実データを出すとき、Animated.View で 150 ms ほどのフェードインを挟むと、切り替えの境目が柔らかくなります。バッテリーや描画コストはほぼ増えないので、試す価値はあります。
つまずきやすいポイント
最後に、Rork 案件のレビューで何度も指摘したことがある落とし穴を挙げておきます。
- 画像領域だけスケルトンを忘れる: テキストはプレースホルダー化したのに、画像プレースホルダーだけ抜けていて、画像が後から「バン」と挿し込まれて画面が揺れるケース。
Imageの親にwidth/heightをしっかり固定し、そのサイズでスケルトンを出してください。 - Shimmer が重すぎる: Shimmer を画面上で 50 個以上同時に動かすと、古い Android で体感フレームレートが落ちます。リストでは表示中のセルだけアニメーションを動かすようにすれば、だいたい解決します。
- スケルトンにブランドカラーを使う: 良かれと思ってブランドカラーの薄い色でスケルトンを塗ると、本物のコンテンツと紛らわしくなります。ニュートラルなグレー(ダークモードでは
#2a2a2a付近)にとどめたほうが、読みやすさは上です。
関連して、ネットワーク不安定時の UI 設計についてはRork アプリの不安定なネットワーク下での UX とエラーステート設計でも整理しています。スケルトンとエラーステートは表裏一体のテーマなので、あわせて見ていただけると全体の完成度が上がります。
次にやってみてほしいこと
いきなり全画面を作り替える必要はありません。まずは起動直後のホーム画面とデータを多く扱うリスト画面の 2 つだけ、スケルトンに置き換えてみてください。ユーザーテストで「待ち時間が気にならなくなった」と言われるはずです。その手応えを掴んでから、他の画面に広げていくのがおすすめです。
もう一歩 UI/UX の体系を整えたい方は、Rork アプリの UX デザインパターン完全ガイドで全体像をまとめていますので、そちらもどうぞ。読んでくださってありがとうございます。あなたのアプリが、ほんの少しでも気持ちよく動くようになれば嬉しいです。