R RenderComp
remotion ui-demo device-mockup animation tutorial

Animated Device Mockups in Remotion: UI Demo Videos Without Screen Recording

Animated Device Mockups in Remotion: UI Demo Videos Without Screen Recording

Every product needs UI demo footage: the hero video on the landing page, the App Store preview, the feature announcement clip, the onboarding walkthrough. The default way to produce it is screen recording — open the app, move the mouse carefully, hope you do not typo, re-record when you do.

There is another way: build the demo as code. In Remotion, a device mockup is a React component, the “screen recording” inside it is a choreographed animation, and the cursor is a spring-driven element you position frame by frame. Nothing is captured; everything is rendered. When the UI changes, you update props and re-render. When marketing wants the same demo with different copy, that is one render command, not a re-shoot.

This guide covers the technical building blocks: device frames, cursor physics, typing and toggle micro-interactions, scroll choreography, and export settings that keep UI text crisp. If you are more interested in the business side of product demo videos — onboarding sequences, feature announcements, personalization — see our SaaS product tour guide. This article is about how the footage itself gets built.


Screen Recordings vs Code-Built UI Demos: Why Developers Switch

A screen recording is a bitmap of one specific take. A code-built demo is a program that produces the take. The differences compound:

  • Re-record vs re-render. UI changed? A recording is obsolete. A coded demo re-renders from the same choreography with the new screens or components.
  • Frame-perfect timing. Human cursor movement is jittery, and pauses are inconsistent. A spring-driven cursor arrives exactly when the narration or caption needs it to, every render.
  • Resolution independence. A recording is locked to the capture resolution. A Remotion composition renders at 1080p, 4K, vertical, square — from the same source.
  • No accidental data leaks. With a real app on screen, real customer names, emails, and numbers can slip into a public video. In a coded demo every pixel is data you chose.
  • Variants are cheap. Different language, different customer name, dark mode, a competitor-comparison cut — each is a props change, not a new recording session.

The trade-off is upfront effort: your first coded demo takes longer than your first screen recording. The second one does not, because everything below is reusable.


Building Reusable Device Frames (Phone, Browser, Desktop)

A device frame is just a styled container with an overflow: hidden screen area. That overflow: hidden is the key architectural decision — it is what later turns a tall content div into a scrollable “screen.”

Here is a phone frame and a browser frame, both accepting arbitrary children as the screen content:

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',
      }}
    >
      {/* Camera cutout */}
      <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,
    }}
  >
    {/* Title bar with traffic lights and 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>
);

Two conventions worth adopting from the start:

  1. System font stack, no external requests. The uiFont constant above uses system fonts, so the render never depends on a network fetch. If you need a specific brand font, self-host the woff2 file in public/ and load it with a local @font-face declaration — never an external stylesheet.
  2. The screen is position: relative. Everything inside — screens, cursors, tap ripples — positions absolutely against it, so the whole frame can be scaled or moved as one unit for camera-style push-ins.

A desktop frame is the browser frame minus the URL bar plus a menu-bar strip; the pattern is identical.


Animating a Cursor with Spring Physics

A cursor that moves linearly between points looks robotic. A cursor driven by spring() accelerates out of its start position and decelerates into its target — the way a human hand actually moves a mouse.

The cleanest structure is a waypoint list. Each waypoint says where the cursor should head and at which frame it should start moving. Summing spring-weighted deltas between consecutive waypoints gives the position at any frame:

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();

  // Start at the first waypoint, then add each spring-driven leg
  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;
  }

  // Click pulse: shrink briefly shortly after arriving at a click waypoint
  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>
  );
};

Notes on the details:

  • The delay option on spring() shifts when each leg begins, so one component handles the entire path without nesting <Sequence> per leg.
  • The click pulse fires at atFrame + 12 — roughly when a spring with this config has visibly arrived. Pair it with a state change in the UI underneath (button turns pressed, panel opens) at the same frame, and the cause-and-effect reads instantly.
  • The soft circular cursor is the standard look for polished product demos. If you prefer an arrow, swap the div for an inline SVG — the positioning math is unchanged.

Spring configs matter as much here as in text animation: lower stiffness gives a lazier, more deliberate glide; higher damping removes overshoot so the cursor never skids past a button. For a deeper treatment of tuning, see the spring animation guide.


Typing, Form-Fill, and Toggle Micro-Interactions

Typing

Typing is the simplest deterministic effect in the toolkit: derive the number of visible characters from the frame, slice the string.

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;

  // Caret: solid while typing, blinks twice per second after finishing
  const caretOn = !done || Math.floor(frame / (fps / 2)) % 2 === 0;

  return (
    <span>
      {visible}
      <span style={{ opacity: caretOn ? 1 : 0 }}>|</span>
    </span>
  );
};

Around 12–16 characters per second reads as fast, confident typing. Drop to 8 if the field content itself is the message.

Form fill

A form-fill sequence is several TypingText instances with staggered startFrame values, plus a focus ring that moves between fields. Drive the ring with interpolate on the border color of whichever field is active at the current frame — the cursor from the previous section clicks each field just before its typing starts, and the scene assembles itself.

Toggles

A toggle is a two-value spring: knob position and track color both derive from the same progress. interpolateColors (from the core remotion package) handles the color leg:

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>
  );
};

One rule applies to all of these: never use CSS transitions or animations. They depend on wall-clock time and will not render correctly in Remotion’s frame-by-frame renderer. Every visual change must derive from useCurrentFrame().


Choreographing Scrolls, Taps, and Screen Transitions

Scrolling

Because the device screen has overflow: hidden, scrolling is a translateY on the content inside it. An ease-out Bézier curve mimics momentum scrolling:

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>
  );
};

The content div is simply taller than the screen — build the full page, then scroll the viewport across it.

Taps

On a phone mockup there is no cursor, so a tap is an expanding, fading circle at the touch point: scale from 0.5 to 1.6 and opacity from 0.5 to 0 over about 12 frames, both interpolate calls anchored at the tap frame. Fire the UI state change 4–6 frames after the ripple starts so the feedback order matches reality (touch first, response second).

Screen transitions

Screens are <Sequence> blocks; a transition is an overlap where the incoming screen animates in on top:

import { AbsoluteFill, Sequence } from 'remotion';

export const Demo: React.FC = () => (
  <AbsoluteFill>
    <Sequence durationInFrames={120}>
      <DashboardScreen />
    </Sequence>
    {/* Starts 10 frames before the dashboard ends — the slide covers it */}
    <Sequence from={110}>
      <SettingsScreen />
    </Sequence>
  </AbsoluteFill>
);

Inside SettingsScreen, a spring-driven translateX from 100% to 0 produces the standard mobile push transition. Since useCurrentFrame() resets to 0 inside each <Sequence>, entrance animations are self-contained and screens stay reorderable. For the timing patterns behind multi-scene sequencing, see the Sequence and Series timing guide.


Keeping Mockups Props-Driven: Swap Product Shots in Seconds

There are two levels of coded UI demos, and both should be parameterized:

  1. Screenshot mockups — the screen content is a static image of your real product (<Img src={staticFile('screens/dashboard.png')} />), and the animation happens around it: frame entrance, camera push-in, cursor overlay, callouts. Fastest to build; ideal when the product already looks good.
  2. Fully coded UI — every button and list row is JSX. More work, but every element can animate, and the “product” in the video is always pixel-crisp at any resolution.

Either way, expose everything variable as composition props:

export type UiDemoProps = {
  url: string;
  headline: string;
  accentColor: string;
  screenshot: string; // file name inside public/screens/
};

Register the composition with defaults in src/Root.tsx, then render any variant from the command line without touching code:

npx remotion render UiDemo out/acme-launch.mp4 \
  --props='{"headline":"Ship reports in one click","accentColor":"#0B84FF","screenshot":"acme-dashboard.png"}'

This is where code-built demos pay off operationally. A new customer logo wall, a localized headline, a rebrand color — each is a --props change. Add a Zod schema to the composition and the same props become editable visually in Remotion Studio, which lets non-developers retint and re-copy the demo safely.


Exporting Crisp UI Footage: Resolution and Codec Settings

UI footage is unforgiving: thin 1px borders, small text, and flat color fields make compression artifacts obvious. A few settings matter:

  • Lower the CRF. For H.264, quality is controlled by --crf (lower is better). The default is fine for photographic content, but UI edges benefit from --crf=15 to --crf=18. Flat UI compresses efficiently, so the file size penalty is small.
  • Render frames as PNG. By default Remotion captures frames as JPEG. --image-format=png removes JPEG ringing around high-contrast edges — exactly where UI text lives. Renders take longer; for a hero video it is worth it.
  • Supersample with --scale=2. Rendering a 1920×1080 composition at scale 2 outputs 3840×2160. Platforms that re-encode aggressively (social feeds especially) start from cleaner source material, and text survives the round trip.
  • Pick fps by motion. 30 fps is fine for cuts and typing. Scroll choreography and cursor glides visibly benefit from 60 fps — set it on the <Composition> and remember your frame-based timings double.
  • Keep dimensions even. H.264 requires even width and height; odd values fail at encode time.

A typical high-quality export:

npx remotion render UiDemo out/demo.mp4 --codec=h264 --crf=16 --image-format=png

For handoff to a video editor who will grade or composite the footage, render ProRes instead (--codec=prores) and keep H.264 for final delivery.


FAQ

Q: Can I use my app’s real React components inside the mockup?

Yes — this is one of Remotion’s quiet superpowers. Import your actual design-system components and drive their visual states from the frame instead of user events (pass checked={frame > 90} rather than waiting for a click handler). Components that depend on timers, useEffect data fetching, or animation libraries need those behaviors mocked or frozen, because Remotion renders each frame deterministically.

Q: Screenshot or fully coded UI — which should I start with?

Screenshots. A PhoneFrame with a real screenshot, a camera push-in, and a cursor overlay already looks professional and takes an afternoon. Move to fully coded UI when you need elements inside the screen to animate independently, or when you re-render often enough that keeping screenshots current becomes its own chore.

Q: How do I do a dark mode variant?

Make theme a prop. In a fully coded UI, map colors through a small theme object; with screenshots, swap the image file per variant. Then it is two render commands with different --props.

Q: Why does my cursor movement look stiff even with springs?

Usually one of two causes: waypoints spaced too evenly (real usage has short hops and long pauses — vary atFrame gaps), or damping set so high the spring becomes a plain ease. Let long moves keep a touch of settle; kill overshoot only on short, precise moves onto small targets.


Summary

Building UI demo footage in code follows a consistent stack:

  1. Wrap screens in reusable device frames with overflow: hidden screen areas
  2. Drive a cursor through waypoints with delayed spring() calls; pulse on click
  3. Derive typing, toggles, and form fills from useCurrentFrame() — never CSS transitions
  4. Scroll by translating tall content inside the clipped screen; switch screens with overlapping <Sequence> blocks
  5. Parameterize everything as props so variants are render commands, not rebuilds
  6. Export with low CRF, PNG frame capture, and 2× scale for crisp UI edges

Skip the scaffolding with RenderComp

The RenderComp library includes a UI Micro pack — cursors, toggles, typing fields, button presses, and tap ripples as tuned, drop-in components — and a SaaS Product Demo pack with complete device frames, screen transitions, and feature-callout scenes. Every template ships as editable TypeScript source with typed props, so you start from a working demo and swap in your product instead of building the rig from scratch.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →