Card Reveal

A white ball drops and bounces, morphs into a card, then flips on its side to reveal your uploaded image with a confetti blast. Solid colors, no gradients.

Preview

A white ball drops and bounces, morphs into a card, then flips on its side to reveal your uploaded image with a confetti blast. Solid colors, no gradients.

Open editor

Props

NameTypeDefault
imagestring (url)""
captionstring""

Composition

ID
CardReveal
Resolution
1920×1080
FPS
60
Duration
4.0s

Source

Copy or download the React source — drop it into your own Remotion project. The only runtime dependency is remotion.

"use client";
import {
  AbsoluteFill,
  Easing,
  Img,
  interpolate,
  spring,
  staticFile,
  useVideoConfig,
} from "remotion";
import { type ClipStyle, resolveClipStyle } from "../../clip-style";
import { proxyExternalImg } from "../../proxy-image";
import { useDesignFrame } from "../../use-design-frame";

export type CardRevealProps = {
  /** The image revealed on the card front (upload, static path, or URL). */
  image: string;
  /** Optional caption that fades in beneath the card. */
  caption: string;
  clipStyle?: ClipStyle;
};

/**
 * A 4-second reveal sting built on flat, solid colors — no gradients, no
 * glow/lighting, no busy background:
 *
 *   0.0–0.95s  a white ball falls under real gravity and settles with one
 *              clean bounce; a contact shadow tightens + darkens as it lands,
 *              and an impact ripple rings out across the floor
 *   0.95–1.7s  the ball *gathers* (anticipation), then springs open into a
 *              soft-cornered square (circle → square, with a little overshoot)
 *   1.7–2.5s   the square winds back, then flips on its vertical axis to reveal
 *              the image; a thin accent focus-frame snaps in around it
 *   ~2.4s      a confetti blast fires as the image lands
 *   2.6–4.0s   everything settles; the caption fades in
 *
 * The motion is the point: gravity ease-in, squash/stretch, anticipation and
 * follow-through, spring overshoot. The image comes from the sibling Image
 * field; background/text/font/accent come from the shared Style controls.
 * Built from flat fills + a contact shadow so it survives web-renderer export.
 */

const SMOOTH = Easing.bezier(0.22, 1, 0.36, 1);

// Timeline (design frames @ 60fps).
const DROP_END = 56;
const MORPH_START = 60;
const MORPH_END = 104;
const FLIP_START = 108;
const FLIP_END = 152;
const BURST = 144;
const CAP_START = 162;
const CAP_END = 216;

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const easeOutCubic = (k: number) => 1 - (1 - k) ** 3;

/** Deterministic per-particle pseudo-random (stable across renders). */
function rand(i: number, salt: number): number {
  const x = Math.sin(i * 127.1 + salt * 311.7) * 43758.5453;
  return x - Math.floor(x);
}

/** Bouncing-ball height profile: 1 (top) → 0 (floor), one clean bounce + a tap. */
function dropHeight(p: number): number {
  if (p < 0.5) {
    const u = p / 0.5;
    return 1 - u * u; // accelerating fall (gravity)
  }
  if (p < 0.76) {
    const u = (p - 0.5) / 0.26;
    return 0.16 * Math.sin(Math.PI * u); // one soft bounce
  }
  if (p < 1) {
    const u = (p - 0.76) / 0.24;
    return 0.04 * Math.sin(Math.PI * u); // tiny settle tap
  }
  return 0;
}
const IMPACTS = [0.5, 0.76]; // p-values where the ball touches the floor

function resolveAsset(src: string | undefined): string | undefined {
  if (!src) return undefined;
  if (/^(data:|blob:)/i.test(src)) return src;
  if (/^https?:/i.test(src)) return proxyExternalImg(src);
  return staticFile(src.replace(/^\//, ""));
}

const CONFETTI = [
  "#FF5E5B",
  "#FFD93D",
  "#6BCB77",
  "#4D96FF",
  "#A66CFF",
  "#FF9F45",
  "#ffffff",
];

function Confetti({
  frame,
  cx,
  cy,
  accent,
}: {
  frame: number;
  cx: number;
  cy: number;
  accent: string;
}) {
  const t = frame - BURST;
  if (t < 0) return null;
  const count = 90;
  const gravity = 0.55;
  const life = 80;
  const palette = [...CONFETTI, accent];

  return (
    <>
      {Array.from({ length: count }).map((_, i) => {
        const angle = rand(i, 1) * Math.PI * 2;
        const speed = 8 + rand(i, 2) * 21;
        const vx = Math.cos(angle) * speed;
        const vy = Math.sin(angle) * speed - 7; // bias the blast upward
        const x = cx + vx * t;
        const y = cy + vy * t + 0.5 * gravity * t * t;
        const op = interpolate(t, [0, 6, life - 22, life], [0, 1, 1, 0], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });
        if (op <= 0) return null;
        const size = 9 + rand(i, 3) * 13;
        const isRect = rand(i, 6) > 0.4;
        const rot = rand(i, 4) * 360 + t * (4 + rand(i, 5) * 12);
        return (
          <div
            key={i}
            style={{
              position: "absolute",
              left: x,
              top: y,
              width: size,
              height: isRect ? size * 0.5 : size,
              background: palette[i % palette.length],
              borderRadius: isRect ? 2 : "50%",
              opacity: op,
              transform: `translate(-50%, -50%) rotate(${rot}deg)`,
              willChange: "transform, opacity",
            }}
          />
        );
      })}
    </>
  );
}

/** Expanding ring on floor impact — a flat stroked ellipse that rings out. */
function Ripple({
  frame,
  start,
  cx,
  y,
  maxR,
  color,
}: {
  frame: number;
  start: number;
  cx: number;
  y: number;
  maxR: number;
  color: string;
}) {
  const t = frame - start;
  if (t < 0 || t > 24) return null;
  const k = t / 24;
  const radius = maxR * easeOutCubic(k);
  const op = (1 - k) * 0.45;
  return (
    <div
      style={{
        position: "absolute",
        left: cx,
        top: y,
        width: radius * 2,
        height: radius * 0.62, // squashed for floor perspective
        transform: "translate(-50%, -50%)",
        border: `${Math.max(1.5, maxR * 0.012)}px solid ${color}`,
        borderRadius: "50%",
        opacity: op,
      }}
    />
  );
}

export const CardReveal: React.FC<CardRevealProps> = ({
  image,
  caption,
  clipStyle,
}) => {
  const frame = useDesignFrame();
  const { fps, width, height } = useVideoConfig();

  const s = resolveClipStyle(clipStyle, {
    background: "#0E0E12",
    color: "#ffffff",
    fontFamily:
      "-apple-system, BlinkMacSystemFont, 'SF Pro Display', Inter, sans-serif",
    accent: "#4D96FF",
  });

  const cx = width / 2;
  const unit = Math.min(width, height);
  const ballD = unit * 0.2;
  // Morph target is a SQUARE with soft corners (circle → square).
  const cardW = unit * 0.44;
  const cardH = cardW;
  const cardR = unit * 0.05;

  // The floor line everything sits on; the object grows UPWARD off it so it
  // reads as physically resting on the ground.
  const groundY = height * 0.62;

  // ── Drop + bounce (real gravity, one clean bounce). ──────────────────────────
  const p = clamp01(frame / DROP_END);
  const fallSpan = groundY; // start above the top edge
  const heightAbove = frame < DROP_END ? fallSpan * dropHeight(p) : 0;
  const proximity = 1 - clamp01(heightAbove / (fallSpan * 0.95)); // 0 high → 1 grounded

  // Squash/stretch at each floor contact.
  let impact = 0;
  if (frame < DROP_END) {
    for (const im of IMPACTS) {
      impact = Math.max(impact, Math.exp(-((p - im) ** 2) / 0.0009));
    }
  }

  // ── Morph: gather (anticipation) → spring open into the square. ──────────────
  const mSpring = spring({
    frame: frame - MORPH_START,
    fps,
    config: { damping: 12, stiffness: 120, mass: 0.9 },
    durationInFrames: MORPH_END - MORPH_START,
  });
  const m = Math.max(0, mSpring); // allow slight overshoot past 1 for jelly
  const gather = interpolate(
    frame,
    [MORPH_START - 10, MORPH_START, MORPH_START + 7],
    [0, 1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );

  const w = lerp(ballD, cardW, Math.min(m, 1.12));
  const h = lerp(ballD, cardH, Math.min(m, 1.12));
  const r = lerp(ballD / 2, cardR, clamp01(m));

  // Combined squash: floor impacts during the drop + the morph gather.
  const sx = (1 + 0.26 * impact) * (1 + 0.1 * gather);
  const sy = (1 - 0.3 * impact) * (1 - 0.12 * gather);

  // Object center, anchored so its bottom stays on the floor as it grows.
  const float = frame > DROP_END ? Math.sin((frame - DROP_END) / 46) * 4 : 0;
  const centerY = groundY - h / 2 - heightAbove + float;

  // ── Flip: wind back, then swing through with overshoot. ──────────────────────
  const flip = spring({
    frame: frame - FLIP_START,
    fps,
    config: { damping: 13, stiffness: 85, mass: 1 },
    durationInFrames: FLIP_END - FLIP_START,
  });
  const windup = interpolate(
    frame,
    [FLIP_START, FLIP_START + 9, FLIP_START + 21],
    [0, 17, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );
  const angle = flip * 180 - windup;

  const flipProg = clamp01((frame - FLIP_START) / (FLIP_END - FLIP_START));
  const bulge = 1 + 0.05 * Math.sin(flipProg * Math.PI); // subtle mid-flip swell
  const settle = spring({
    frame: frame - FLIP_END,
    fps,
    config: { damping: 9, stiffness: 140, mass: 0.8 },
  });
  const popScale = bulge * (frame >= FLIP_END ? lerp(0.975, 1, settle) : 1);

  // ── Accent focus-frame snapping in on reveal. ────────────────────────────────
  const frameIn = interpolate(frame, [FLIP_END - 8, FLIP_END + 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: SMOOTH,
  });
  const framePad = unit * 0.022;
  const frameScale = lerp(1.14, 1, frameIn);

  // ── Caption. ──────────────────────────────────────────────────────────────────
  const capIn = interpolate(frame, [CAP_START, CAP_END], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: SMOOTH,
  });

  // ── Contact shadow geometry. ─────────────────────────────────────────────────
  const shadowW = w * lerp(1.55, 1.04, proximity);
  const shadowOpacity = lerp(0.05, 0.26, proximity);

  const resolved = resolveAsset(image);
  const faceShadow = "0 18px 40px rgba(0,0,0,0.32)";

  // 2D flip: squeeze the card horizontally to ~0 at the midpoint and swap the
  // visible face there. We deliberately AVOID a 3D flip (rotateY + preserve-3d
  // + backface-visibility): the in-browser / web-renderer export rasterizes
  // each frame from the DOM and does NOT support 3D transforms, so the image
  // face never painted and the card exported blank white. A scaleX flip looks
  // the same and rasterizes correctly in every export path.
  const angleRad = (angle * Math.PI) / 180;
  const flipScaleX = Math.max(0.001, Math.abs(Math.cos(angleRad)));
  const showFront = Math.cos(angleRad) < 0; // past 90° → image side faces us

  return (
    <AbsoluteFill
      style={{
        background: s.background,
        fontFamily: s.fontFamily,
        overflow: "hidden",
      }}
    >
      {/* Contact shadow on the floor — tightens + darkens as the ball lands. */}
      <div
        style={{
          position: "absolute",
          left: cx,
          top: groundY,
          width: shadowW,
          height: shadowW * 0.26,
          transform: "translate(-50%, -50%)",
          borderRadius: "50%",
          background: "#000000",
          opacity: shadowOpacity,
          filter: `blur(${unit * 0.012}px)`,
        }}
      />

      {/* Impact ripples — one per floor contact. */}
      <Ripple
        frame={frame}
        start={Math.round(DROP_END * IMPACTS[0]!)}
        cx={cx}
        y={groundY}
        maxR={ballD * 1.7}
        color={s.accent}
      />
      <Ripple
        frame={frame}
        start={Math.round(DROP_END * IMPACTS[1]!)}
        cx={cx}
        y={groundY}
        maxR={ballD * 1.1}
        color={s.accent}
      />

      {/* Ball → square → flip. One flat element; the flip is a 2D scaleX so it
          rasterizes correctly in every export path. */}
      <div
        style={{
          position: "absolute",
          left: cx,
          top: centerY,
          transform: `translate(-50%, -50%) scaleX(${sx * flipScaleX}) scaleY(${sy})`,
        }}
      >
        <div
          style={{
            width: w,
            height: h,
            borderRadius: r,
            overflow: "hidden",
            background: "#ffffff",
            boxShadow: faceShadow,
            transform: `scale(${popScale})`,
          }}
        >
          {showFront &&
            (resolved ? (
              <Img
                src={resolved}
                crossOrigin="anonymous"
                alt=""
                style={{ width: "100%", height: "100%", objectFit: "cover" }}
              />
            ) : (
              <div
                style={{
                  width: "100%",
                  height: "100%",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  background: "#e9e9ee",
                  color: "#9aa0aa",
                  fontSize: unit * 0.03,
                  fontWeight: 500,
                }}
              >
                Upload an image
              </div>
            ))}
        </div>
      </div>

      {/* Accent focus-frame — snaps around the square on reveal (flat stroke). */}
      {frameIn > 0.01 && (
        <div
          style={{
            position: "absolute",
            left: cx,
            top: centerY,
            width: cardW + framePad * 2,
            height: cardH + framePad * 2,
            transform: `translate(-50%, -50%) scale(${frameScale})`,
            border: `${Math.max(2, unit * 0.004)}px solid ${s.accent}`,
            borderRadius: cardR + framePad,
            opacity: frameIn,
          }}
        />
      )}

      {/* Confetti blast. */}
      <Confetti frame={frame} cx={cx} cy={centerY} accent={s.accent} />

      {/* Caption */}
      {caption.trim() && (
        <div
          style={{
            position: "absolute",
            left: 0,
            right: 0,
            top: groundY + unit * 0.06,
            textAlign: "center",
            color: s.color,
            fontSize: Math.round(unit * 0.04),
            fontWeight: 600,
            letterSpacing: "-0.01em",
            opacity: capIn,
            transform: `translateY(${(1 - capIn) * 16}px)`,
          }}
        >
          {caption}
        </div>
      )}
    </AbsoluteFill>
  );
};
Save as CardReveal/CardReveal.tsx