Code Morph

A code panel morphs between versions of a file, with surviving tokens gliding to their new line and column while removed code dissolves and new code fades in

Installation
$ pnpm dlx shadcn@latest add @remocn/code-morph

Usage

CodeMorph works like Keynote's Magic Move for code. A flat editor panel shows one version of a file, then morphs into the next. A token-level diff pairs every token that survives the edit. Each survivor is one element that glides from its old line and column to its new ones, so the eye can follow it. In the default content, "/api/invoices" lifts out of fetch(...) and lands inside useSWR(...). Removed code blurs out first, the survivors travel, and new tokens fade in line by line. The panel is sized once to fit every version and never moves, so code that did not change stays exactly where it was. The changed lines hold a short accent highlight.

tsx
import { Backdrop } from "@/components/remocn/backdrop";
import { CodeMorph } from "@/components/remocn/code-morph";
 
export const ApiSimplification = () => (
  <Backdrop
    fill={{ type: "color", value: "#f1eee7" }}
    padding={0}
    radius={0}
    shadow=""
  >
    <CodeMorph />
  </Backdrop>
);

The component paints only the panel, so it composites over any scene. The backdrop supplies the color. Sizes are reference pixels at 1280×720 and scale with the composition, and a panel that would not fit shrinks as a whole. Token positions come from the monospace grid, not from DOM measurement. Everything derives from the Remotion frame, so seeking and rendering are deterministic.

Your own code

Pass the versions as steps. Each at is the frame, at 30 fps, where the morph into that version starts. The first version shows from frame 0. Indentation shared by every line is removed, so template literals can stay indented. The panel takes its height from the tallest version and its width from the longest line, so a shorter version leaves empty editor space below it. Keep each version to about 16 lines at the default size. Longer files shrink the whole panel.

tsx
import { Composition } from "remotion";
import { Backdrop } from "@/components/remocn/backdrop";
import {
  CodeMorph,
  type CodeMorphStep,
  getCodeMorphDuration,
} from "@/components/remocn/code-morph";
 
const steps: CodeMorphStep[] = [
  {
    at: 0,
    code: `
      import { defineConfig } from "vite";
      import react from "@vitejs/plugin-react";
 
      export default defineConfig({
        plugins: [react()],
      });
    `,
  },
  {
    at: 45,
    code: `
      import { defineConfig } from "vite";
      import react from "@vitejs/plugin-react";
      import tailwindcss from "@tailwindcss/vite";
 
      export default defineConfig({
        plugins: [react(), tailwindcss()],
      });
    `,
  },
  {
    at: 110,
    code: `
      import { defineConfig } from "vite";
      import react from "@vitejs/plugin-react";
      import tailwindcss from "@tailwindcss/vite";
 
      export default defineConfig({
        plugins: [react(), tailwindcss()],
        server: { port: 3000 },
      });
    `,
  },
];
 
const ViteSetup = () => (
  <Backdrop
    fill={{ type: "color", value: "#0a0a0a" }}
    padding={0}
    radius={0}
    shadow=""
  >
    <CodeMorph
      steps={steps}
      filename="vite.config.ts"
      theme="light"
      accentColor="#22c55e"
    />
  </Backdrop>
);
 
export const RemotionRoot = () => (
  <Composition
    id="ViteSetup"
    component={ViteSetup}
    durationInFrames={getCodeMorphDuration({ steps })}
    fps={30}
    width={1280}
    height={720}
  />
);

Identical tokens are matched in reading order, words first. Punctuation survives only when it stays with the words on its line. Closing brackets follow their opening bracket, and a line that only moved travels as one block. Everything else fades out or in.

Timing

At 30 fps, speed={1} and the default content:

FramesMotion
0–48The 16-line version holds
48–64Removed tokens fade, blur and shrink slightly, top line first
54–80Surviving tokens glide to their new line and column; each destination line starts one frame after the line above
58–78Line numbers 9–16 fade out behind the last line to move; the panel keeps its size
68–84New tokens fade in line by line
68–100Lines 1, 4, 6 and 7 rise to an accent highlight and hold it
100–114The highlight fades
114–138The new version holds

A morph lasts morphDuration frames, 36 by default, and its phases scale with it. In a 36-frame morph, removed tokens leave over frames 0–16, survivors glide over 6–32, and new tokens arrive over 20–36. Nothing overshoots: the code lands exactly on the grid. The highlight keeps its own timing: 8 frames up, 24 held and 14 down. If a step's at comes before the previous morph has finished, it waits for that morph, so two morphs never overlap.

codeMorphLength is 114, the frame where the default highlight fades out. getCodeMorphTimeline({ steps, morphDuration, highlight }) returns every morph's start, end and highlight frames, plus settled. getCodeMorphDuration({ steps, morphDuration, highlight, speed }) returns the composition length with a 24-frame hold, 138 by default. For another frame rate, multiply by fps / 30 and round up.

Props11

PropTypeDefault
steps
{ at: number; code: string }[]Invoices before and after

Versions of the file in order. at is the 30 fps frame where the morph into that version starts; the first version shows from frame 0

morphDuration
number36

Frames from the first removed token leaving to the last new token settling, clamped to 12–120; the phases scale with it

highlight
booleantrue

Hold an accent band on the lines each morph changed

fontSize
number20

Code size in reference px at 720p, clamped to 10–40; the panel shrinks as a whole if it would not fit

lineNumbers
booleantrue

Show right-aligned line numbers in a gutter

filename
string"invoices.tsx"

Label of the file tab; an empty string hides the tab

windowDots
booleantrue

Show three neutral window dots in the header

theme
"dark" | "light""dark"

Panel and syntax palette

accentColor
string"#0ea5e9"

Strings, numbers and the changed-line highlight

speed
number1

Playback multiplier; 0 freezes the first version

className
string—

Optional class name on the full-frame root

  • steps{ at: number; code: string }[]

    Versions of the file in order. at is the 30 fps frame where the morph into that version starts; the first version shows from frame 0

    DefaultInvoices before and after

  • morphDurationnumber

    Frames from the first removed token leaving to the last new token settling, clamped to 12–120; the phases scale with it

    Default36

  • highlightboolean

    Hold an accent band on the lines each morph changed

    Defaulttrue

  • fontSizenumber

    Code size in reference px at 720p, clamped to 10–40; the panel shrinks as a whole if it would not fit

    Default20

  • lineNumbersboolean

    Show right-aligned line numbers in a gutter

    Defaulttrue

  • filenamestring

    Label of the file tab; an empty string hides the tab

    Default"invoices.tsx"

  • windowDotsboolean

    Show three neutral window dots in the header

    Defaulttrue

  • theme"dark" | "light"

    Panel and syntax palette

    Default"dark"

  • accentColorstring

    Strings, numbers and the changed-line highlight

    Default"#0ea5e9"

  • speednumber

    Playback multiplier; 0 freezes the first version

    Default1

  • classNamestring

    Optional class name on the full-frame root