Remotionでデバイスモックアップ動画 — 画面録画に頼らないUIデモの作り方
Remotionでデバイスモックアップ動画 — 画面録画に頼らないUIデモの作り方
プロダクトにUIデモ映像は欠かせません。LPのヒーロー動画、App Storeのプレビュー、新機能の告知クリップ、オンボーディングの操作説明——。これらを作る定番の方法は画面録画です。アプリを開き、マウスを慎重に動かし、タイポしないことを祈り、失敗したら録り直す。
もうひとつの方法があります。デモを「コード」として組むことです。Remotionでは、デバイスモックアップはReactコンポーネントであり、その中の「画面録画」は振付されたアニメーションであり、カーソルはフレーム単位で位置が決まるspring駆動の要素です。何もキャプチャしません。すべてレンダリングで生成します。UIが変わったらpropsを更新して再レンダリング。マーケチームが別コピーの同じデモをほしがったら、それは再撮影ではなくレンダーコマンド1回です。
この記事では技術的な構成要素を扱います——デバイスフレーム、カーソルの物理演算、タイピングやトグルのマイクロインタラクション、スクロールの振付、そしてUIテキストをくっきり保つ書き出し設定。オンボーディングや機能告知などビジネス面の設計に興味がある場合は、SaaSオンボーディング動画のガイドを参照してください。本記事は「映像そのものをどう組み立てるか」に集中します。
画面録画 vs コードで組むUIデモ — 開発者が乗り換える理由
画面録画は「特定の1テイクのビットマップ」です。コードで組んだデモは「テイクを生成するプログラム」です。この違いは積み重なっていきます。
- 録り直し vs 再レンダリング。 UIが変わったら録画は陳腐化します。コード化されたデモは、新しい画面を差し込んで同じ振付から再レンダリングするだけです。
- フレーム単位で正確なタイミング。 人間のカーソル操作はブレるうえ、間の取り方も毎回違います。spring駆動のカーソルは、ナレーションや字幕が必要とする瞬間に、毎回正確に到着します。
- 解像度からの独立。 録画はキャプチャ解像度に縛られます。Remotionのコンポジションは同じソースから1080p・4K・縦型・正方形を出力できます。
- データ漏洩事故が起きない。 実アプリを画面に映すと、実在する顧客名・メールアドレス・数値が公開動画に紛れ込むリスクがあります。コード化されたデモでは、すべてのピクセルが自分で選んだデータです。
- バリエーションが安価。 言語違い、顧客名違い、ダークモード、比較カット——それぞれpropsの変更であって、新しい撮影セッションではありません。
トレードオフは初期コストです。最初のコード化デモは最初の画面録画より時間がかかります。しかし2本目からはかかりません。以下で作るものがすべて再利用できるからです。
再利用できるデバイスフレームを作る(スマホ・ブラウザ・デスクトップ)
デバイスフレームの実体は、overflow: hidden のスクリーン領域を持つスタイル付きコンテナにすぎません。この overflow: hidden が最重要の設計判断です——のちに縦長のコンテンツ div を「スクロールする画面」に変えるのはこれです。
スマホフレームとブラウザフレームの例です。どちらも任意の子要素をスクリーンコンテンツとして受け取ります。
import React from 'react';
const uiFont = '-apple-system, "Segoe UI", Roboto, sans-serif';
export const PhoneFrame: React.FC<{ children: React.ReactNode }> = ({
children,
}) => (
<div
style={{
width: 390,
height: 844,
borderRadius: 54,
background: '#111',
padding: 14,
boxShadow: '0 40px 80px rgba(0, 0, 0, 0.35)',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: 40,
background: '#fff',
overflow: 'hidden',
position: 'relative',
}}
>
{/* カメラ部分の切り欠き */}
<div
style={{
position: 'absolute',
top: 12,
left: '50%',
width: 110,
height: 28,
marginLeft: -55,
borderRadius: 14,
background: '#111',
zIndex: 10,
}}
/>
{children}
</div>
</div>
);
export const BrowserFrame: React.FC<{
url: string;
children: React.ReactNode;
}> = ({ url, children }) => (
<div
style={{
width: 1280,
height: 800,
borderRadius: 12,
background: '#e8e8ec',
boxShadow: '0 30px 60px rgba(0, 0, 0, 0.25)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontFamily: uiFont,
}}
>
{/* 信号ボタンとURLバー */}
<div
style={{
height: 44,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 16px',
}}
>
{['#ff5f57', '#febc2e', '#28c840'].map((color) => (
<div
key={color}
style={{ width: 12, height: 12, borderRadius: 6, background: color }}
/>
))}
<div
style={{
flex: 1,
margin: '0 80px',
height: 28,
borderRadius: 8,
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
color: '#666',
}}
>
{url}
</div>
</div>
<div style={{ flex: 1, background: '#fff', position: 'relative', overflow: 'hidden' }}>
{children}
</div>
</div>
);
最初から採用しておきたい規約が2つあります。
- システムフォントスタックを使い、外部リクエストをゼロにする。 上の
uiFont定数はシステムフォント指定なので、レンダリングがネットワーク取得に依存しません。ブランドフォントが必要な場合は、woff2ファイルをpublic/にセルフホストしてローカルの@font-face宣言で読み込みます——外部スタイルシートの参照は使いません。 - スクリーンは
position: relativeにする。 内側のすべて——画面・カーソル・タップ波紋——がこれを基準に絶対配置されるため、フレーム全体をひとつのユニットとしてスケール・移動でき、カメラのプッシュイン演出がそのまま作れます。
デスクトップフレームは「ブラウザフレームからURLバーを外してメニューバーを足したもの」で、パターンは同一です。
spring物理演算でカーソルを動かす
点から点へ直線的に動くカーソルはロボットのように見えます。spring() で駆動するカーソルは、始点から加速し、目標に向けて減速します——人間の手がマウスを動かすときの動きそのものです。
もっとも綺麗な構造はウェイポイント(経由地点)のリストです。各ウェイポイントは「カーソルがどこへ向かうか」「何フレーム目から動き出すか」を宣言します。連続するウェイポイント間のspring加重の差分を合計すれば、任意のフレームでの位置が求まります。
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
type Waypoint = {
x: number;
y: number;
atFrame: number;
click?: boolean;
};
export const Cursor: React.FC<{ waypoints: Waypoint[] }> = ({ waypoints }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 最初のウェイポイントから開始し、spring駆動の区間を順に加算する
let x = waypoints[0].x;
let y = waypoints[0].y;
for (let i = 1; i < waypoints.length; i++) {
const progress = spring({
frame,
fps,
delay: waypoints[i].atFrame,
config: { mass: 0.6, stiffness: 120, damping: 17 },
});
x += (waypoints[i].x - waypoints[i - 1].x) * progress;
y += (waypoints[i].y - waypoints[i - 1].y) * progress;
}
// クリックパルス: クリック地点に到着した直後に一瞬縮む
const clickScale = waypoints
.filter((w) => w.click)
.reduce((scale, w) => {
const pulse = interpolate(
frame,
[w.atFrame + 12, w.atFrame + 16, w.atFrame + 24],
[1, 0.8, 1],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
);
return scale * pulse;
}, 1);
return (
<AbsoluteFill style={{ pointerEvents: 'none' }}>
<div
style={{
position: 'absolute',
left: x,
top: y,
width: 28,
height: 28,
marginLeft: -14,
marginTop: -14,
borderRadius: '50%',
background: 'rgba(0, 0, 0, 0.35)',
border: '2px solid rgba(255, 255, 255, 0.9)',
transform: `scale(${clickScale})`,
}}
/>
</AbsoluteFill>
);
};
細部のポイントを整理します。
spring()のdelayオプションで各区間の開始をずらせるため、区間ごとに<Sequence>を入れ子にしなくても、1コンポーネントで経路全体を扱えます。- クリックパルスは
w.atFrame + 12で発火します——この設定のspringが目視で到着したあたりです。同じフレームで下のUIの状態変化(ボタンが押下状態になる、パネルが開く)を起こすと、因果関係が一瞬で伝わります。 - ソフトな円形カーソルは、洗練されたプロダクトデモの標準的な見た目です。矢印にしたい場合は
divをインラインSVGに差し替えるだけで、位置計算はそのまま使えます。
spring設定の影響はテキストアニメーションと同じくらい大きく効きます。stiffness を下げるとゆったりした滑走に、damping を上げるとオーバーシュートが消え、カーソルがボタンを通り過ぎて滑ることがなくなります。チューニングの詳細はspringアニメーションのガイドを参照してください。
タイピング・フォーム入力・トグルのマイクロインタラクション
タイピング
タイピングはこのツールキットで最もシンプルな決定論的エフェクトです。フレームから表示文字数を導出し、文字列をsliceするだけです。
import { useCurrentFrame, useVideoConfig } from 'remotion';
export const TypingText: React.FC<{
text: string;
startFrame: number;
charsPerSecond?: number;
}> = ({ text, startFrame, charsPerSecond = 14 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const charsShown = Math.max(
0,
Math.floor(((frame - startFrame) / fps) * charsPerSecond),
);
const visible = text.slice(0, charsShown);
const done = charsShown >= text.length;
// キャレット: 入力中は常時表示、入力完了後は毎秒2回点滅
const caretOn = !done || Math.floor(frame / (fps / 2)) % 2 === 0;
return (
<span>
{visible}
<span style={{ opacity: caretOn ? 1 : 0 }}>|</span>
</span>
);
};
毎秒12〜16文字あたりが「速くて迷いのないタイピング」に見えます。入力内容そのものがメッセージの場合は8文字程度まで落とします。
フォーム入力
フォーム入力のシーケンスは、startFrame をずらした複数の TypingText に、フィールド間を移動するフォーカスリングを重ねたものです。現在のフレームでアクティブなフィールドのボーダー色を interpolate で駆動します。前セクションのカーソルが各フィールドをタイピング開始直前にクリックするようにすれば、シーンはひとりでに組み上がります。
トグル
トグルは2値のspringです。ノブの位置とトラックの色が同じprogressから導出されます。色の側は interpolateColors(remotion コアパッケージに含まれます)が担当します。
import {
interpolate,
interpolateColors,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
export const Toggle: React.FC<{ onAtFrame: number }> = ({ onAtFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
delay: onAtFrame,
config: { mass: 0.4, stiffness: 260, damping: 20 },
});
const knobX = interpolate(progress, [0, 1], [2, 22]);
const track = interpolateColors(progress, [0, 1], ['#d1d5db', '#34c759']);
return (
<div
style={{
width: 48,
height: 28,
borderRadius: 14,
background: track,
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 2,
left: knobX,
width: 24,
height: 24,
borderRadius: 12,
background: '#fff',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)',
}}
/>
</div>
);
};
これらすべてに共通するルールがひとつあります。CSSのtransitionとanimationは絶対に使わないこと。 これらは実時間(壁時計)に依存するため、Remotionのフレーム単位レンダラーでは正しく描画されません。すべての視覚変化は useCurrentFrame() から導出する必要があります。
スクロール・タップ・画面遷移の振付
スクロール
デバイスのスクリーンが overflow: hidden なので、スクロールは中身のコンテンツへの translateY です。イーズアウトのベジェ曲線が慣性スクロールを再現します。
import { Easing, interpolate, useCurrentFrame } from 'remotion';
const ScrollingScreen: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const frame = useCurrentFrame();
const scrollY = interpolate(frame, [90, 150], [0, -620], {
easing: Easing.bezier(0.22, 1, 0.36, 1),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)` }}>{children}</div>
</div>
);
};
コンテンツの div はスクリーンより縦に長いだけです——ページ全体を組んでおいて、その上をビューポートが移動するイメージです。
タップ
スマホモックアップにはカーソルがないので、タップは接触点で広がりながら消える円で表現します。scale を0.5から1.6へ、opacity を0.5から0へ、約12フレームかけて変化させ、どちらの interpolate もタップフレームを基準にします。UIの状態変化は波紋開始の4〜6フレーム後に起こすと、フィードバックの順序が現実と一致します(タッチが先、反応が後)。
画面遷移
各画面は <Sequence> ブロックで、遷移は「入ってくる画面が上に重なってアニメーションするオーバーラップ」です。
import { AbsoluteFill, Sequence } from 'remotion';
export const Demo: React.FC = () => (
<AbsoluteFill>
<Sequence durationInFrames={120}>
<DashboardScreen />
</Sequence>
{/* ダッシュボード終了の10フレーム前に開始 — スライドが覆い隠す */}
<Sequence from={110}>
<SettingsScreen />
</Sequence>
</AbsoluteFill>
);
SettingsScreen の内部でspring駆動の translateX を 100% から 0 に動かせば、モバイル標準のプッシュ遷移になります。useCurrentFrame() は各 <Sequence> の内側で0にリセットされるため、エントランスアニメーションは自己完結し、画面の並べ替えも自由です。複数シーンの時間設計のパターンはSequenceのタイミングガイドにまとめています。
モックアップをprops駆動に保つ — 製品画面を数秒で差し替える
コード化されたUIデモには2つのレベルがあり、どちらもパラメータ化しておくべきです。
- スクリーンショット型モックアップ — スクリーンの中身は実プロダクトの静止画(
<Img src={staticFile('screens/dashboard.png')} />)で、アニメーションはその周囲で起こします。フレームの登場、カメラのプッシュイン、カーソルのオーバーレイ、コールアウト。最速で組めて、プロダクトの見た目がすでに良い場合に最適です。 - 完全コード化UI — すべてのボタンとリスト行がJSXです。手間は増えますが、あらゆる要素がアニメーションでき、動画内の「プロダクト」はどの解像度でもピクセル単位でくっきりします。
どちらの場合も、変化しうるものはすべてコンポジションpropsとして公開します。
export type UiDemoProps = {
url: string;
headline: string;
accentColor: string;
screenshot: string; // public/screens/ 内のファイル名
};
src/Root.tsx でデフォルト値付きのコンポジションとして登録すれば、コードに触れずにコマンドラインから任意のバリアントをレンダリングできます。
npx remotion render UiDemo out/acme-launch.mp4 \
--props='{"headline":"レポート作成をワンクリックで","accentColor":"#0B84FF","screenshot":"acme-dashboard.png"}'
コードで組んだデモの運用上の見返りはここにあります。新しいロゴ壁、ローカライズした見出し、リブランド後の色——どれも --props の変更1つです。さらにコンポジションにZodスキーマを追加すれば、同じpropsがRemotion Studio上でビジュアル編集できるようになり、非開発者でも安全に色替えやコピー差し替えができます。
UIがくっきり映る書き出し — 解像度とコーデックの設定
UI映像は圧縮に対して容赦がありません。1pxの細い罫線、小さいテキスト、フラットな色面は、圧縮アーティファクトを目立たせます。効く設定は次のとおりです。
- CRFを下げる。 H.264の品質は
--crfで制御します(低いほど高品質)。デフォルトは写真的なコンテンツには十分ですが、UIのエッジには--crf=15〜--crf=18が効きます。フラットなUIは圧縮効率が良いので、ファイルサイズの増加はわずかです。 - フレームをPNGでキャプチャする。 RemotionはデフォルトでフレームをJPEGとして取り込みます。
--image-format=pngにすると、高コントラストのエッジ——まさにUIテキストのある場所——のJPEGリンギングが消えます。レンダリングは長くなりますが、ヒーロー動画なら投資に値します。 --scale=2でスーパーサンプリングする。 1920×1080のコンポジションをscale 2でレンダリングすると3840×2160で出力されます。積極的に再エンコードするプラットフォーム(特にSNSフィード)でも、より綺麗なソースから始まるため、テキストが再圧縮を生き延びます。- fpsは動きで選ぶ。 カット割りとタイピングなら30fpsで十分です。スクロールの振付とカーソルの滑走は60fpsで目に見えて良くなります——
<Composition>に設定し、フレームベースのタイミングが2倍になることを忘れずに。 - 縦横を偶数に保つ。 H.264は幅・高さともに偶数を要求します。奇数だとエンコード時に失敗します。
高品質書き出しの典型例です。
npx remotion render UiDemo out/demo.mp4 --codec=h264 --crf=16 --image-format=png
グレーディングや合成を行う動画編集者に渡す場合はProRes(--codec=prores)でレンダリングし、最終納品用にはH.264を使い分けます。
よくある質問(FAQ)
Q: アプリの実際のReactコンポーネントをモックアップ内で使えますか?
使えます——これはRemotionの隠れた強みのひとつです。実際のデザインシステムのコンポーネントをimportし、ユーザーイベントの代わりにフレームから視覚状態を駆動します(クリックハンドラを待つのではなく checked={frame > 90} を渡す)。ただし、タイマー・useEffect でのデータ取得・アニメーションライブラリに依存するコンポーネントは、それらの挙動をモックするか凍結する必要があります。Remotionは各フレームを決定論的にレンダリングするためです。
Q: スクリーンショット型と完全コード化UI、どちらから始めるべきですか?
スクリーンショットからです。実スクリーンショットを入れた PhoneFrame にカメラのプッシュインとカーソルオーバーレイを重ねるだけで、すでにプロフェッショナルに見え、半日で作れます。スクリーン内部の要素を独立してアニメーションさせたくなったとき、あるいは再レンダリングの頻度が高くてスクリーンショットの更新自体が手間になってきたときに、完全コード化UIへ移行してください。
Q: ダークモード版はどう作りますか?
テーマをpropにします。完全コード化UIなら小さなテーマオブジェクト経由で色をマッピングし、スクリーンショット型ならバリアントごとに画像ファイルを差し替えます。あとは --props を変えたレンダーコマンド2回で完成です。
Q: springを使っているのにカーソルの動きが硬く見えるのはなぜですか?
原因はたいてい2つのどちらかです。ウェイポイントの間隔が均等すぎる(実際の操作には短いホップと長い停止があります——atFrame の間隔にばらつきを持たせてください)か、damping が高すぎてspringがただのイージングになっているかです。長い移動にはわずかな揺り戻しを残し、小さいターゲットへの短く正確な移動でのみオーバーシュートを消しましょう。
まとめ
コードでUIデモ映像を組む手順は一貫したスタックに従います。
overflow: hiddenのスクリーン領域を持つ再利用可能なデバイスフレームで画面を包むdelay付きのspring()でカーソルをウェイポイント経由で動かし、クリック時にパルスさせる- タイピング・トグル・フォーム入力は
useCurrentFrame()から導出する——CSSのtransitionは使わない - クリップされたスクリーン内の縦長コンテンツをtranslateしてスクロールし、
<Sequence>のオーバーラップで画面を切り替える - すべてをpropsとしてパラメータ化し、バリアントを「作り直し」ではなく「レンダーコマンド」にする
- 低CRF・PNGフレームキャプチャ・2倍スケールでUIのエッジをくっきり書き出す
土台作りをスキップするなら RenderComp →
RenderCompのライブラリには、カーソル・トグル・タイピングフィールド・ボタン押下・タップ波紋を調整済みコンポーネントとして収めた UI Micro パックと、デバイスフレーム一式・画面遷移・機能コールアウトのシーンを備えた SaaS Product Demo パックがあります。すべてのテンプレートは型付きpropsを持つ編集可能なTypeScriptソースとして提供されるため、リグをゼロから組む代わりに、動くデモから始めて自分のプロダクトを差し込むだけです。