All demos

Changelog — New Chat Components

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/chat-changelog

Render it locally

Renders the MP4 on your machine with the Remotion CLI.

$ pnpm dlx remotion render chat-changelog out/chat-changelog.mp4 --scale=2 --crf=15 --x264-preset=slower --jpeg-quality=95 --gl=angle

The prompt

The prompt this video was generated from.

Redo the chat-changelog video in the new house style (introducing-videorc / introducing-shadcn / introducing-remocn) for remocn.dev/changelog#2026-06-27-chat-primitives. Keep the story on the chat drop: message-bubble + typing-indicator primitives, and the three flows (chat-flow, iMessage, Telegram) as one message API with three skins — prove it by replaying the SAME conversation in every skin. Obsidian + lime register, Manrope, mono only for the shell command. Bans: letter-spacing, uppercase, badges, pulsing, installation pills, swirl and ripple transitions. Invent a NEW registry transition instead (→ bubble-bloom: a typing pill inflates into a chat-bubble mask that reveals the next scene). Reuse the introducing-remocn outro with the new R-mark logo. The video ships on the changelog page.

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,
  Sequence,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import {
  TransitionSeries,
  linearTiming,
  type TransitionPresentation,
  type TransitionPresentationComponentProps,
} from "@remotion/transitions";
import { slide } from "@remotion/transitions/slide";
import { loadFont as loadSans } from "@remotion/google-fonts/Manrope";
import { loadFont as loadMono } from "@remotion/google-fonts/GeistMono";

import { demoAsset } from "@/lib/demo-assets";
import { RemocnUIProvider, type RemocnTheme } from "@/lib/remocn-ui";

import { ShortSlideRight } from "@/components/remocn/short-slide-right";
import { LineByLineSlide } from "@/components/remocn/line-by-line-slide";
import { ShaderSimplexNoise } from "@/components/remocn/shader-simplex-noise";
import { ShaderSmokeRing } from "@/components/remocn/shader-smoke-ring";
import { bubbleBloom } from "@/components/remocn/bubble-bloom";

// The chat primitives — the subject of this changelog.
import { MessageBubble } from "@/components/remocn/message-bubble";
import { TypingIndicator } from "@/components/remocn/typing-indicator";
import {
  ChatFlow,
  chatFlowDuration,
  type ChatMessage,
} from "@/components/remocn/chat-flow";
import {
  ImessageChatFlow,
  imessageChatFlowDuration,
  type ImessageMessage,
} from "@/components/remocn/imessage-chat-flow";
import {
  TelegramChatFlow,
  telegramChatFlowDuration,
  type TelegramMessage,
} from "@/components/remocn/telegram-chat-flow";

// ---------------------------------------------------------------------------
// Register — the shipped remocn.dev brand, same world as introducing-remocn.
// ---------------------------------------------------------------------------
const { fontFamily: SANS_FAMILY } = loadSans("normal", {
  subsets: ["latin"],
  weights: ["400", "500", "600", "700", "800"],
});
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`;

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

// Message bubbles restyled for the obsidian stage: lime outgoing, glass
// incoming. The hook speaks through the announced primitives themselves.
const CHAT_THEME: Partial<RemocnTheme> = {
  primary: LIME,
  primaryForeground: OBSIDIAN,
  muted: "rgba(242,242,242,0.1)",
  foreground: INK,
  card: OBSIDIAN,
};

// Readability scrim over the backdrop shader.
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.78 * strength}) 100%)`,
    }}
  />
);

// The stage: one quiet simplex field under the scrim. Rendered at the root
// AND handed to bubble-bloom, whose covering layers re-render the same
// frame-identical field — the shader stays alive through the transitions.
const StageBackdrop: React.FC = () => (
  <AbsoluteFill>
    <ShaderSimplexNoise
      speed={0.35}
      colors={["#141318", "#1a1922", "#232231"]}
      stepsPerColor={2}
      softness={0.8}
    />
    <Scrim strength={0.85} />
  </AbsoluteFill>
);

// ---------------------------------------------------------------------------
// The one conversation. All three skin stations replay the SAME messages —
// only the skin changes. That repetition IS the one-API argument.
// ---------------------------------------------------------------------------
const LINE_ME = "Did the chat components ship?";
const LINE_THEM = "Just landed — reactions included.";
const CONTACT = { name: "shadcn", avatar: demoAsset("shadcn-avatar.png") };

const CF_THREAD: ChatMessage[] = [
  { from: "me", text: LINE_ME },
  { from: "them", text: LINE_THEM, reaction: "🔥" },
];
const IM_THREAD: ImessageMessage[] = [
  { from: "me", text: LINE_ME },
  { from: "them", text: LINE_THEM, reaction: "❤️" },
];
const TG_THREAD: TelegramMessage[] = [
  { from: "me", text: LINE_ME, time: "9:41" },
  { from: "them", text: LINE_THEM, reaction: "👍", time: "9:41" },
];

// ---------------------------------------------------------------------------
// Scene timings (frames @ 30fps). Transitions overlap.
// ---------------------------------------------------------------------------
const S_HOOK = 168; //     typing dots → two-message pain exchange
const S_DROP = 132; //     "New in Remocn" → "Chat components"
const S_TAGLINE = 70; //   "Conversations that play themselves"
const S_MECH = 120; //     three claims + the two primitive names
const S_CMD = 126; //      typed command + skin rolodex
const S_OUTRO = 150; //    smoke ring → R mark → wordmark

const T_BLOOM = 56; //     bubble-bloom launch: pill pops, dwells typing, then blooms
const T_SLIDE = 15; //     lateral push between skin stations
const T_BLUR = 16; //      blur crossfade into the outro

// ===========================================================================
// Scene 1 — Hook. A typing indicator alone on the stage, then the pain lands
// as a real two-message exchange, rendered by the primitives being announced.
// ===========================================================================
const TYPING_IN = 6;
const MSG1_AT = 38;
const MSG2_AT = 100;

const PopBubble: React.FC<{
  at: number;
  variant: "incoming" | "outgoing";
  children: ReactNode;
}> = ({ at, variant, children }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const s = spring({
    frame: frame - at,
    fps,
    config: { damping: 15, stiffness: 160, mass: 0.7 },
  });
  const style =
    frame < at
      ? { opacity: 0, translateY: 12, scale: 0.9 }
      : {
          opacity: Math.min(1, (frame - at) / 5),
          translateY: (1 - s) * 12,
          scale: 0.9 + s * 0.1,
        };
  return (
    <MessageBubble variant={variant} theme={CHAT_THEME} style={style} maxWidth="88%">
      {children}
    </MessageBubble>
  );
};

const HookScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const typingPop = spring({
    frame: frame - TYPING_IN,
    fps,
    config: { damping: 15, stiffness: 160, mass: 0.7 },
  });
  const typingOpacity =
    interpolate(frame, [TYPING_IN, TYPING_IN + 5], [0, 1], clampOpts) *
    interpolate(frame, [MSG1_AT - 4, MSG1_AT], [1, 0], clampOpts);

  return (
    <AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
      <div
        style={{
          width: 470,
          display: "flex",
          flexDirection: "column",
          gap: 16,
          transform: "scale(1.9)",
        }}
      >
        {/* Slot 1 — the typing pill resolves into the first message. */}
        <div style={{ position: "relative" }}>
          <PopBubble at={MSG1_AT} variant="incoming">
            Every product talks in chat now.
          </PopBubble>
          {typingOpacity > 0 ? (
            <div style={{ position: "absolute", inset: 0 }}>
              <MessageBubble
                variant="incoming"
                theme={CHAT_THEME}
                style={{
                  opacity: typingOpacity,
                  translateY: (1 - typingPop) * 12,
                  scale: 0.9 + typingPop * 0.1,
                }}
              >
                <div style={{ padding: "4px 2px", display: "flex" }}>
                  <TypingIndicator color={MUTED} size={7} gap={4} amplitude={3.5} />
                </div>
              </MessageBubble>
            </div>
          ) : null}
        </div>

        {/* Slot 2 — the pain, sent from our side. */}
        <PopBubble at={MSG2_AT} variant="outgoing">
          Animating bubbles by hand? A day of keyframes.
        </PopBubble>
      </div>
    </AbsoluteFill>
  );
};

// ===========================================================================
// Scene 2 — The drop. The typing dots inside the blooming bubble hand off to
// "New in Remocn" mid-flight — the indicator resolves into the message, the
// line rides the bubble to center and lifts away, then "Chat components"
// lands and plays its own exit (hard cut into the tagline).
// ===========================================================================
// Must match the bubble-bloom call below — the drop line replicates the
// transition's growth/travel curves to sit exactly where the dots are.
const DROP_ORIGIN = { x: 0.25, y: 0.7 };

const DropScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { width, height, durationInFrames } = useVideoConfig();

  // Mirror of bubble-bloom's curves (linearTiming over the scene's first
  // T_BLOOM frames): same grow, same glide — so the line lands on the dots.
  const p = Math.min(1, frame / T_BLOOM);
  const grow = interpolate(p, [0.42, 0.96], [0, 1], {
    ...clampOpts,
    easing: Easing.inOut(Easing.cubic),
  });
  const travel = interpolate(grow, [0, 0.85], [0, 1], {
    ...clampOpts,
    easing: Easing.out(Easing.cubic),
  });
  const cx = (DROP_ORIGIN.x + (0.5 - DROP_ORIGIN.x) * travel) * width;
  const cy = (DROP_ORIGIN.y + (0.5 - DROP_ORIGIN.y) * travel) * height;

  // The hand-off: the dots dissolve at grow ≈ 0.12–0.38 (local frames
  // ~33–37) and the line resolves in their place, inside the same bubble.
  const lineIn = interpolate(frame, [30, 42], [0, 1], {
    ...clampOpts,
    easing: Easing.out(Easing.cubic),
  });
  const lineOut = interpolate(frame, [58, 70], [0, 1], {
    ...clampOpts,
    easing: Easing.in(Easing.cubic),
  });

  const enter = interpolate(frame, [68, 86], [0, 1], {
    ...clampOpts,
    easing: Easing.bezier(0.22, 1, 0.36, 1),
  });
  const exitP = interpolate(
    frame,
    [durationInFrames - 16, durationInFrames - 2],
    [0, 1],
    { ...clampOpts, easing: Easing.in(Easing.cubic) },
  );

  return (
    <AbsoluteFill>
      {lineIn > 0 && lineOut < 1 ? (
        <span
          style={{
            position: "absolute",
            left: cx,
            top: cy,
            transform: `translate(-50%, -50%) translateY(${
              (1 - lineIn) * 10 - lineOut * 12
            }px) scale(${0.92 + lineIn * 0.08})`,
            fontFamily: SANS,
            fontWeight: 400,
            fontSize: 46,
            letterSpacing: "-0.03em",
            color: INK,
            whiteSpace: "nowrap",
            opacity: lineIn * (1 - lineOut),
            filter: `blur(${(1 - lineIn) * 6 + lineOut * 6}px)`,
          }}
        >
          New in Remocn
        </span>
      ) : null}
      <Sequence from={68}>
        <AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
          <span
            style={{
              fontFamily: SANS,
              fontWeight: 400,
              fontSize: 78,
              letterSpacing: "-0.03em",
              color: INK,
              opacity: enter * (1 - exitP),
              transform: `translateY(${(1 - enter) * 14 + exitP * -10}px) scale(${
                0.96 + enter * 0.04 - exitP * 0.05
              })`,
              filter: `blur(${(1 - enter) * 8 + exitP * 6}px)`,
            }}
          >
            Chat components
          </span>
        </AbsoluteFill>
      </Sequence>
    </AbsoluteFill>
  );
};

// ===========================================================================
// Scene 3 — Tagline, its own typographic beat.
// ===========================================================================
const TaglineScene: React.FC = () => (
  <AbsoluteFill>
    <ShortSlideRight
      text="Conversations that play themselves"
      fontSize={46}
      fontWeight={400}
      color={INK}
    />
  </AbsoluteFill>
);

// ===========================================================================
// Phone — a device frame whose screen holds a running chat flow. Sized to a
// real handset aspect (iPhone 16 Pro ≈ 2.17:1) while fitting the 720 canvas.
// ===========================================================================
const PHONE_W = 302;
const PHONE_H = 620;
const STATUS_H = 40;

const SignalIcon: React.FC = () => (
  <svg width={17} height={11} viewBox="0 0 17 11" fill="#000">
    <rect x={0} y={7} width={3} height={4} rx={1} />
    <rect x={4.5} y={5} width={3} height={6} rx={1} />
    <rect x={9} y={2.5} width={3} height={8.5} rx={1} />
    <rect x={13.5} y={0} width={3} height={11} rx={1} />
  </svg>
);
const WifiIcon: React.FC = () => (
  <svg width={16} height={12} viewBox="0 0 16 12" fill="none">
    <path d="M8 10.2a1.4 1.4 0 1 0 0-2.8 1.4 1.4 0 0 0 0 2.8Z" fill="#000" />
    <path
      d="M3.2 5.2A7 7 0 0 1 12.8 5.2M5.2 7.2a4.1 4.1 0 0 1 5.6 0"
      stroke="#000"
      strokeWidth={1.5}
      strokeLinecap="round"
    />
  </svg>
);
const BatteryIcon: React.FC = () => (
  <svg width={26} height={12} viewBox="0 0 26 12" fill="none">
    <rect x={0.5} y={0.5} width={22} height={11} rx={3} stroke="#000" strokeOpacity={0.4} />
    <rect x={2} y={2} width={17} height={8} rx={1.6} fill="#000" />
    <rect x={24} y={4} width={1.5} height={4} rx={0.75} fill="#000" fillOpacity={0.4} />
  </svg>
);

const StatusBar: React.FC = () => (
  <div
    style={{
      position: "relative",
      flexShrink: 0,
      height: STATUS_H,
      background: "#ffffff",
      display: "flex",
      alignItems: "center",
      justifyContent: "space-between",
      padding: "0 18px 0 22px",
      fontFamily: SANS,
      zIndex: 3,
    }}
  >

Showing the first 400 of 1093 lines. View the full file on GitHub.