Typography — New Text Animations
Install this video
Adds the full composition, its remocn dependencies, and the prompt to your Remotion project via the shadcn registry.
$ pnpm dlx shadcn@latest add kapishdima/remocn-demo/typographyRender it locally
Renders the MP4 on your machine with the Remotion CLI.
$ pnpm dlx remotion render typography out/typography.mp4 --scale=2 --crf=15 --x264-preset=slower --jpeg-quality=95 --gl=angleThe prompt
Reconstructed draftA reconstruction of the prompt this video was generated from.
Make a showcase video for the new text animations we added to remocn. The gimmick should be that each animation introduces itself by animating its own name — so the typewriter effect types out "typewriter", the split-text effect splits "split-text", and so on down the list. Put it over an image backdrop rather than a flat color, and keep the transitions between each one dynamic and kinetic so it doesn't feel like a static list. Keep it snappy, no long holds, just effect after effect proving itself. Use remocn's actual text animation components for all of it.
The code
The exact source the AI wrote — the same files the install command puts in your project.
import React, { type ReactNode } from "react";
import {
AbsoluteFill,
Easing,
Series,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import {
TransitionSeries,
linearTiming,
type TransitionPresentation,
type TransitionPresentationComponentProps,
} from "@remotion/transitions";
import { loadFont as loadSans } from "@remotion/google-fonts/Manrope";
import { loadFont as loadMono } from "@remotion/google-fonts/GeistMono";
import { RemocnUIProvider } from "@/lib/remocn-ui";
import { caretWipe } from "@/components/remocn/caret-wipe";
import { ShaderSimplexNoise } from "@/components/remocn/shader-simplex-noise";
import { ShaderSmokeRing } from "@/components/remocn/shader-smoke-ring";
// The wave's cast — every effect appears in the montage animating its own name.
import { SoftBlurIn } from "@/components/remocn/soft-blur-in";
import { PerCharacterRise } from "@/components/remocn/per-character-rise";
import { BottomUpLetters } from "@/components/remocn/bottom-up-letters";
import { TopDownLetters } from "@/components/remocn/top-down-letters";
import { SpringScaleIn } from "@/components/remocn/spring-scale-in";
import { MicroScaleFade } from "@/components/remocn/micro-scale-fade";
import { MaskRevealUp } from "@/components/remocn/mask-reveal-up";
import { LineByLineSlide } from "@/components/remocn/line-by-line-slide";
import { KineticCenterBuild } from "@/components/remocn/kinetic-center-build";
import { FocusBlurResolve } from "@/components/remocn/focus-blur-resolve";
import { BlurOutUp } from "@/components/remocn/blur-out-up";
import { ScaleDownFade } from "@/components/remocn/scale-down-fade";
import { FadeThrough } from "@/components/remocn/fade-through";
import { PerWordCrossfade } from "@/components/remocn/per-word-crossfade";
import { SharedAxisY } from "@/components/remocn/shared-axis-y";
import { SharedAxisZ } from "@/components/remocn/shared-axis-z";
import { ShortSlideDown } from "@/components/remocn/short-slide-down";
import { ShortSlideRight } from "@/components/remocn/short-slide-right";
// Manrope, bound to the CSS variable every remocn typography component reads.
const { fontFamily: SANS_FAMILY } = loadSans("normal", {
subsets: ["latin"],
weights: ["400", "500", "600", "700"],
});
const { fontFamily: MONO_FAMILY } = loadMono("normal", {
subsets: ["latin"],
weights: ["400", "500"],
});
const SANS =
"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif";
const MONO = `${MONO_FAMILY}, ui-monospace, SFMono-Regular, monospace`;
// The shipped remocn.dev brand — warm obsidian + one lime accent. No
// letter-spacing, sentence case, weight 400 everywhere.
const OBSIDIAN = "#141318";
const INK = "#f2f2f2";
const MUTED = "rgba(242,242,242,0.62)";
const FAINT = "rgba(242,242,242,0.4)";
const LIME = "#C3E88D";
const clampOpts = {
extrapolateLeft: "clamp" as const,
extrapolateRight: "clamp" as const,
};
// Readability scrim over the shared shader field.
const Scrim: React.FC<{ strength?: number }> = ({ strength = 1 }) => (
<AbsoluteFill
style={{
background: `radial-gradient(120% 120% at 50% 42%, rgba(20,19,24,${
0.3 * strength
}) 0%, rgba(20,19,24,${0.82 * strength}) 100%)`,
}}
/>
);
// ---------------------------------------------------------------------------
// Scene timings (frames @ 30fps). Transitions overlap.
// ---------------------------------------------------------------------------
const S_HOOK = 70;
const S_WAVE = 74;
const S_MECH = 80;
const S_DET = 74;
const S_VALUE = 100;
const S_CMD = 104;
const S_OUTRO = 150;
const T_X = 14; // crossfade
const T_CARET = 22; // caret-wipe (the new registry transition)
const T_BLUR = 16; // blur crossfade
// ===========================================================================
// Intro — the hook and the promise.
// ===========================================================================
const HookScene: React.FC = () => (
<AbsoluteFill>
<SoftBlurIn
text="Text is most of what a demo video says"
fontSize={46}
fontWeight={400}
color={INK}
/>
</AbsoluteFill>
);
const WaveScene: React.FC = () => (
<AbsoluteFill>
<KineticCenterBuild
text="18 new ways to make it move"
fontSize={60}
fontWeight={400}
color={INK}
/>
</AbsoluteFill>
);
// ===========================================================================
// The montage — every effect introduced by animating its own name. The
// counter holds perfectly still while the effects change beneath it.
// ===========================================================================
const MONT_SIZE = 60;
const MONT_WEIGHT = 400;
const SPEED_MONT = 1.5;
const TOTAL_EFFECTS = 18;
type MontEffect = { name: string; dur: number; node: ReactNode };
const s = SPEED_MONT;
const ENTRANCES: MontEffect[] = [
{
name: "soft-blur-in",
dur: 30,
node: <SoftBlurIn text="Soft blur in" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "per-character-rise",
dur: 36,
node: <PerCharacterRise text="Per character rise" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "bottom-up-letters",
dur: 34,
node: <BottomUpLetters text="Bottom up letters" staggerDelay={2} fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "top-down-letters",
dur: 34,
node: <TopDownLetters text="Top down letters" staggerDelay={2} fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "spring-scale-in",
dur: 28,
node: <SpringScaleIn text="Spring scale in" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "micro-scale-fade",
dur: 28,
node: <MicroScaleFade text="Micro scale fade" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "mask-reveal-up",
dur: 32,
node: <MaskRevealUp text={"Mask reveal\nup"} fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "line-by-line-slide",
dur: 36,
node: <LineByLineSlide text={"Line by line\nslide"} fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "kinetic-center-build",
dur: 36,
node: <KineticCenterBuild text="Kinetic center build" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "focus-blur-resolve",
dur: 34,
node: <FocusBlurResolve text="Focus blur resolve" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
];
const SWAPS: MontEffect[] = [
{
name: "blur-out-up",
dur: 32,
node: <BlurOutUp text="Blur out up" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "scale-down-fade",
dur: 32,
node: <ScaleDownFade text="Scale down fade" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "fade-through",
dur: 36,
node: <FadeThrough fromText="Static text" toText="Fade through" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "per-word-crossfade",
dur: 38,
node: <PerWordCrossfade fromText="Word by word" toText="Per word crossfade" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "shared-axis-y",
dur: 34,
node: <SharedAxisY fromText="Outgoing" toText="Shared axis y" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "shared-axis-z",
dur: 36,
node: <SharedAxisZ fromText="Outgoing" toText="Shared axis z" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "short-slide-down",
dur: 30,
node: <ShortSlideDown text="Short slide down" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
{
name: "short-slide-right",
dur: 30,
node: <ShortSlideRight text="Short slide right" fontSize={MONT_SIZE} fontWeight={MONT_WEIGHT} color={INK} speed={s} />,
},
];
const ENTR_TOTAL = ENTRANCES.reduce((a, e) => a + e.dur, 0);
const SWAP_TOTAL = SWAPS.reduce((a, e) => a + e.dur, 0);
// The still `NN / 18` counter — reads the montage-half's local frame and maps
// it to the active effect; the chrome never moves, only its value ticks.
const MontageCounter: React.FC<{ effects: MontEffect[]; baseIndex: number }> = ({
effects,
baseIndex,
}) => {
const frame = useCurrentFrame();
let active = 0;
let acc = 0;
for (let i = 0; i < effects.length; i++) {
if (frame >= acc) active = i;
acc += effects[i].dur;
}
const tag = effects[active];
const vis = interpolate(frame, [0, 8], [0, 1], clampOpts);
// The value settles with a soft fade (no movement) on each switch.
let start = 0;
for (let i = 0; i < active; i++) start += effects[i].dur;
const valueOpacity = interpolate(frame - start, [0, 7], [0.4, 1], {
...clampOpts,
easing: Easing.out(Easing.cubic),
});
return (
<div
style={{
position: "absolute",
left: 70,
bottom: 56,
display: "flex",
alignItems: "baseline",
gap: 13,
opacity: vis,
fontFamily: SANS,
color: MUTED,
}}
>
<span
style={{
fontSize: 22,
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
opacity: valueOpacity,
}}
>
{String(baseIndex + active + 1).padStart(2, "0")}
</span>
<span style={{ fontSize: 15, fontWeight: 400, color: FAINT }}>
/ {String(TOTAL_EFFECTS).padStart(2, "0")}
</span>
<span
style={{ fontSize: 16, fontWeight: 400, color: MUTED, opacity: valueOpacity }}
>
{tag.name}
</span>
</div>
);
};
const MontageScene: React.FC<{ effects: MontEffect[]; baseIndex: number }> = ({
effects,
baseIndex,
}) => (
<AbsoluteFill>
<Series>
{effects.map((e) => (
<Series.Sequence key={e.name} durationInFrames={e.dur}>
{e.node}
</Series.Sequence>
))}
</Series>
<MontageCounter effects={effects} baseIndex={baseIndex} />
</AbsoluteFill>
);
// ===========================================================================
// The argument — why these are different (interpolate/spring, deterministic).
// ===========================================================================
const MechScene: React.FC = () => (
<AbsoluteFill>
<ShortSlideRight
text="No CSS keyframes — just interpolate and spring"
fontSize={44}
fontWeight={400}
color={INK}
/>
</AbsoluteFill>
);
const DeterministicScene: React.FC = () => (
<AbsoluteFill>
<SoftBlurIn
text="Frame 40 looks the same on every render"
fontSize={46}
fontWeight={400}
color={INK}
/>
</AbsoluteFill>
);
const ValueScene: React.FC = () => (
<AbsoluteFill>
<LineByLineSlide
text={"Seek-safe by default\nOne import each\nThe code is yours"}
fontSize={48}
fontWeight={400}
color={INK}
/>
</AbsoluteFill>
);
// ===========================================================================
// One command — the exact effect that opened the montage, installed live.
// A plain typed mono line (never a pill).
// ===========================================================================
const CMD_FULL = "npx shadcn add @remocn/soft-blur-in";
const PKG_AT = CMD_FULL.indexOf("@remocn/");
const CMD_START = 8;
const CommandScene: React.FC = () => {
const frame = useCurrentFrame();
const typed = Math.max(
0,
Math.min(CMD_FULL.length, Math.floor((frame - CMD_START) * 1.4)),
);
const visible = CMD_FULL.slice(0, typed);
const done = typed >= CMD_FULL.length;
const caretOn = done ? Math.floor(frame / 15) % 2 === 0 : true;
const enter = interpolate(frame, [CMD_START - 4, CMD_START], [0, 1], clampOpts);
return (
<AbsoluteFill
style={{ alignItems: "center", justifyContent: "center", opacity: enter }}
>
<span style={{ fontFamily: MONO, fontSize: 29, color: MUTED }}>
<span style={{ color: FAINT }}>$ </span>
<span>{visible.slice(0, Math.min(typed, PKG_AT))}</span>
<span style={{ color: LIME }}>
{typed > PKG_AT ? visible.slice(PKG_AT) : ""}
</span>
<span
style={{
display: "inline-block",
width: 12,
height: 28,
marginLeft: 4,
verticalAlign: "-5px",
background: caretOn ? MUTED : "transparent",
}}
/>
</span>
</AbsoluteFill>
);
};
// ===========================================================================
// Outro — the introducing-remocn lockup, reused with the new logo. A smoke
// ring blooms open, the R mark draws itself on, "emocn" slides out from behind
// it to assemble the Remocn wordmark, and remocn.dev settles underneath.
// Tracking dropped to default to honor the no-letter-spacing rule.
// ===========================================================================
const MARK_VIEWBOX = "0 0 124.06 134.26";
const MARK_RATIO = 124.06 / 134.26;Showing the first 400 of 712 lines. View the full file on GitHub.