StealThis .dev
Remotion Medium

Product Review Video (Remotion)

An 8-second animated product review card for SaaS marketing — a large avatar springs in, reviewer name and title fade up, five gold stars fill left-to-right, a quote block expands with word-by-word text reveal, then a company logo pill and verified-customer badge slide in from the bottom.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

import React from "react";
import {
  AbsoluteFill,
  Composition,
  Easing,
  Sequence,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

// ── Design tokens ──────────────────────────────────────────────────────
const BG = "#0a0a0f";
const SURFACE = "#12121a";
const CARD = "#1a1a2e";
const BRAND = "#6366f1";
const BRAND_2 = "#8b5cf6";
const ACCENT = "#06b6d4";
const TEXT = "#f8fafc";
const TEXT_MUTED = "rgba(248,250,252,0.55)";
const GOLD = "#f59e0b";
const FONT = "system-ui, -apple-system, sans-serif";

// ── Data ───────────────────────────────────────────────────────────────
const REVIEWER_NAME = "Sarah K.";
const REVIEWER_FULL = "Sarah Kauffman";
const REVIEWER_TITLE = "Head of Marketing";
const REVIEWER_COMPANY = "Flowbase";
const REVIEWER_INITIALS = "SK";
const REVIEW_TEXT =
  "Flowbase transformed our entire go-to-market motion. We shipped our last campaign in three days instead of three weeks — and the quality was noticeably better. I cannot imagine going back.";
const STAR_COUNT = 5;
const VERIFIED_LABEL = "Verified customer";
const COMPANY_TAGLINE = "flowbase.io";

// ── Avatar (large, spring entrance) ───────────────────────────────────
const Avatar: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
  const scale = spring({
    frame,
    fps,
    from: 0,
    to: 1,
    config: { damping: 12, stiffness: 180, mass: 0.8 },
  });
  const translateY = spring({
    frame,
    fps,
    from: -60,
    to: 0,
    config: { damping: 14, stiffness: 160, mass: 0.9 },
  });
  const opacity = interpolate(frame, [0, 8], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        opacity,
        transform: `scale(${scale}) translateY(${translateY}px)`,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 0,
      }}
    >
      {/* Avatar ring */}
      <div
        style={{
          width: 116,
          height: 116,
          borderRadius: "50%",
          background: `conic-gradient(from 180deg, ${BRAND}, ${BRAND_2}, ${ACCENT}, ${BRAND})`,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          boxShadow: `0 0 40px ${BRAND}55, 0 0 80px ${BRAND_2}22`,
        }}
      >
        <div
          style={{
            width: 104,
            height: 104,
            borderRadius: "50%",
            background: `linear-gradient(135deg, ${BRAND}cc 0%, ${BRAND_2}cc 100%)`,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontFamily: FONT,
            fontWeight: 800,
            fontSize: 36,
            color: TEXT,
            letterSpacing: "-0.5px",
          }}
        >
          {REVIEWER_INITIALS}
        </div>
      </div>
    </div>
  );
};

// ── Reviewer name + title ──────────────────────────────────────────────
const ReviewerInfo: React.FC<{ frame: number; fps: number }> = ({
  frame,
  fps,
}) => {
  const nameOpacity = interpolate(frame, [0, 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const nameY = spring({
    frame,
    fps,
    from: 20,
    to: 0,
    config: { damping: 16, stiffness: 140 },
  });

  const titleOpacity = interpolate(frame, [8, 26], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const titleY = spring({
    frame: Math.max(0, frame - 8),
    fps,
    from: 14,
    to: 0,
    config: { damping: 16, stiffness: 140 },
  });

  return (
    <div style={{ textAlign: "center" }}>
      <div
        style={{
          opacity: nameOpacity,
          transform: `translateY(${nameY}px)`,
          fontFamily: FONT,
          fontWeight: 800,
          fontSize: 26,
          color: TEXT,
          letterSpacing: "-0.3px",
        }}
      >
        {REVIEWER_NAME}{" "}
        <span style={{ color: TEXT_MUTED, fontWeight: 600 }}>
          {REVIEWER_TITLE} at {REVIEWER_COMPANY}
        </span>
      </div>
      <div
        style={{
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
          fontFamily: FONT,
          fontWeight: 400,
          fontSize: 14,
          color: TEXT_MUTED,
          marginTop: 4,
          letterSpacing: "0.3px",
        }}
      >
        {REVIEWER_FULL} · {REVIEWER_COMPANY}
      </div>
    </div>
  );
};

// ── Star rating (fills left-to-right) ─────────────────────────────────
const StarRating: React.FC<{ frame: number; fps: number }> = ({
  frame,
  fps,
}) => {
  return (
    <div
      style={{
        display: "flex",
        gap: 10,
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      {Array.from({ length: STAR_COUNT }).map((_, i) => {
        const delay = i * 7;
        const f = Math.max(0, frame - delay);
        const scale = spring({
          frame: f,
          fps,
          from: 0,
          to: 1,
          config: { damping: 8, stiffness: 220, mass: 0.4 },
        });
        const glowOpacity = interpolate(f, [0, 12], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });

        return (
          <div
            key={i}
            style={{
              fontSize: 38,
              color: GOLD,
              transform: `scale(${scale})`,
              display: "inline-block",
              filter: `drop-shadow(0 0 ${10 * glowOpacity}px ${GOLD}cc)`,
            }}
          >

          </div>
        );
      })}
    </div>
  );
};

// ── Quote block (expands + word-by-word reveal) ────────────────────────
const QuoteBlock: React.FC<{ frame: number; fps: number }> = ({
  frame,
  fps,
}) => {
  const words = REVIEW_TEXT.split(" ");

  // Outer block expand
  const blockHeight = interpolate(frame, [0, 22], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  const blockOpacity = interpolate(frame, [0, 10], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Left accent bar scale
  const accentScale = spring({
    frame,
    fps,
    from: 0,
    to: 1,
    config: { damping: 18, stiffness: 160 },
  });

  return (
    <div
      style={{
        opacity: blockOpacity,
        overflow: "hidden",
        maxHeight: `${blockHeight * 200}px`,
        transition: "max-height 0.3s",
        width: "100%",
        position: "relative",
        paddingLeft: 28,
      }}
    >
      {/* Left accent bar */}
      <div
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          bottom: 0,
          width: 4,
          borderRadius: 2,
          background: `linear-gradient(180deg, ${BRAND} 0%, ${BRAND_2} 100%)`,
          transform: `scaleY(${accentScale})`,
          transformOrigin: "top center",
        }}
      />

      {/* Quote mark */}
      <div
        style={{
          fontFamily: "Georgia, 'Times New Roman', serif",
          fontSize: 72,
          lineHeight: 0.8,
          color: BRAND,
          opacity: 0.6,
          marginBottom: 6,
          userSelect: "none",
        }}
      >
        "
      </div>

      {/* Words */}
      <div
        style={{
          display: "flex",
          flexWrap: "wrap",
          gap: "4px 8px",
          lineHeight: 1.7,
        }}
      >
        {words.map((word, i) => {
          const wordDelay = 6 + i * 4;
          const f = Math.max(0, frame - wordDelay);
          const wordOpacity = interpolate(f, [0, 12], [0, 1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          });
          const wordY = interpolate(f, [0, 12], [8, 0], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
            easing: Easing.out(Easing.quad),
          });

          return (
            <span
              key={i}
              style={{
                opacity: wordOpacity,
                transform: `translateY(${wordY}px)`,
                display: "inline-block",
                fontFamily: FONT,
                fontStyle: "italic",
                fontWeight: 400,
                fontSize: 22,
                color: TEXT,
                letterSpacing: "0.1px",
              }}
            >
              {word}
            </span>
          );
        })}
      </div>
    </div>
  );
};

// ── Company logo + verified badge (slide in from bottom) ───────────────
const CompanyBadge: React.FC<{ frame: number; fps: number }> = ({
  frame,
  fps,
}) => {
  const translateY = spring({
    frame,
    fps,
    from: 40,
    to: 0,
    config: { damping: 16, stiffness: 130 },
  });
  const opacity = interpolate(frame, [0, 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        opacity,
        transform: `translateY(${translateY}px)`,
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        width: "100%",
      }}
    >
      {/* Company logo pill */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
          background: `linear-gradient(135deg, ${BRAND}18 0%, ${BRAND_2}18 100%)`,
          border: `1px solid ${BRAND}44`,
          borderRadius: 40,
          padding: "8px 18px",
        }}
      >
        {/* Logo icon */}
        <div
          style={{
            width: 28,
            height: 28,
            borderRadius: 8,
            background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_2} 100%)`,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontFamily: FONT,
            fontWeight: 800,
            fontSize: 13,
            color: TEXT,
          }}
        >
          F
        </div>
        <span
          style={{
            fontFamily: FONT,
            fontWeight: 700,
            fontSize: 16,
            color: TEXT,
            letterSpacing: "-0.2px",
          }}
        >
          {REVIEWER_COMPANY}
        </span>
        <span
          style={{
            fontFamily: FONT,
            fontWeight: 400,
            fontSize: 13,
            color: TEXT_MUTED,
          }}
        >
          {COMPANY_TAGLINE}
        </span>
      </div>

      {/* Verified badge */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 7,
          background: "rgba(16,185,129,0.12)",
          border: "1px solid rgba(16,185,129,0.35)",
          borderRadius: 40,
          padding: "8px 16px",
        }}
      >
        {/* Checkmark circle */}
        <div
          style={{
            width: 20,
            height: 20,
            borderRadius: "50%",
            backgroundColor: "#10b981",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontSize: 12,
            color: "#fff",
            fontWeight: 700,
          }}
        >

        </div>
        <span
          style={{
            fontFamily: FONT,
            fontWeight: 600,
            fontSize: 13,
            color: "#10b981",
            letterSpacing: "0.2px",
          }}
        >
          {VERIFIED_LABEL}
        </span>
      </div>
    </div>
  );
};

// ── Divider ────────────────────────────────────────────────────────────
const Divider: React.FC<{ frame: number }> = ({ frame }) => {
  const scaleX = interpolate(frame, [0, 20], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  return (
    <div
      style={{
        width: "100%",
        height: 1,
        background: `linear-gradient(90deg, ${BRAND}66 0%, ${BRAND_2}44 50%, transparent 100%)`,
        transform: `scaleX(${scaleX})`,
        transformOrigin: "left center",
      }}
    />
  );
};

// ── Main composition ───────────────────────────────────────────────────
export const ProductReviewVideo: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  // Global fade out last 0.5 s
  const globalOpacity = interpolate(
    frame,
    [durationInFrames - 15, durationInFrames],
    [1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  // Card scale entrance
  const cardScale = spring({
    frame,
    fps,
    from: 0.88,
    to: 1,
    config: { damping: 18, stiffness: 120, mass: 1 },
  });
  const cardOpacity = interpolate(frame, [0, 12], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Sequence offsets (in frames)
  const AVATAR_START = 0;
  const INFO_START = 18;
  const STARS_START = 42;
  const DIVIDER_1_START = 72;
  const QUOTE_START = 84;
  const DIVIDER_2_START = 180;
  const BADGE_START = 194;

  return (
    <AbsoluteFill
      style={{
        backgroundColor: BG,
        opacity: globalOpacity,
        fontFamily: FONT,
      }}
    >
      {/* Ambient background gradients */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background: `radial-gradient(ellipse 900px 600px at 30% 20%, ${BRAND}12 0%, transparent 70%),
                       radial-gradient(ellipse 700px 500px at 75% 80%, ${BRAND_2}0e 0%, transparent 65%)`,
        }}
      />

      {/* Card */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          width: 860,
          transform: `translate(-50%, -50%) scale(${cardScale})`,
          opacity: cardOpacity,
          background: `linear-gradient(160deg, ${CARD}ee 0%, ${SURFACE}f5 100%)`,
          border: `1px solid rgba(99,102,241,0.22)`,
          borderRadius: 20,
          padding: "44px 52px",
          boxShadow: `0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(99,102,241,0.08), inset 0 1px 0 rgba(255,255,255,0.06)`,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          gap: 0,
        }}
      >
        {/* 1 — Avatar */}
        <Sequence from={AVATAR_START} layout="none">
          <Avatar frame={frame - AVATAR_START} fps={fps} />
        </Sequence>

        <div style={{ height: 18 }} />

        {/* 2 — Reviewer name + title */}
        <Sequence from={INFO_START} layout="none">
          <ReviewerInfo frame={frame - INFO_START} fps={fps} />
        </Sequence>

        <div style={{ height: 20 }} />

        {/* 3 — Star rating */}
        <Sequence from={STARS_START} layout="none">
          <StarRating frame={frame - STARS_START} fps={fps} />
        </Sequence>

        <div style={{ height: 24 }} />

        {/* Divider 1 */}
        <Sequence from={DIVIDER_1_START} layout="none">
          <Divider frame={frame - DIVIDER_1_START} />
        </Sequence>

        <div style={{ height: 24 }} />

        {/* 4 — Quote block */}
        <Sequence from={QUOTE_START} layout="none">
          <QuoteBlock frame={frame - QUOTE_START} fps={fps} />
        </Sequence>

        <div style={{ height: 24 }} />

        {/* Divider 2 */}
        <Sequence from={DIVIDER_2_START} layout="none">
          <Divider frame={frame - DIVIDER_2_START} />
        </Sequence>

        <div style={{ height: 20 }} />

        {/* 5 — Company logo + verified badge */}
        <Sequence from={BADGE_START} layout="none">
          <CompanyBadge frame={frame - BADGE_START} fps={fps} />
        </Sequence>
      </div>

      {/* Bottom product name watermark */}
      <div
        style={{
          position: "absolute",
          bottom: 24,
          left: "50%",
          transform: "translateX(-50%)",
          fontFamily: FONT,
          fontWeight: 600,
          fontSize: 12,
          color: TEXT_MUTED,
          letterSpacing: "2px",
          textTransform: "uppercase",
          opacity: interpolate(frame, [60, 80], [0, 0.6], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          }),
        }}
      >
        Flowbase · Customer Stories
      </div>
    </AbsoluteFill>
  );
};

// ── Remotion Root ──────────────────────────────────────────────────────
export const RemotionRoot: React.FC = () => (
  <Composition
    id="ProductReviewVideo"
    component={ProductReviewVideo}
    durationInFrames={240}
    fps={30}
    width={1280}
    height={720}
  />
);

Product Review Video

A premium single-review animation built for SaaS product marketing. The composition opens with a large avatar circle flying in on a spring with a conic-gradient ring, followed by the reviewer’s full name and role fading up. Five gold stars spring in one by one from left to right, each with a subtle glow. A quote block then expands — an accent bar scales down from the top while the review text reveals word-by-word. The sequence ends with a branded company logo pill and a green verified-customer badge sliding in from below.

The card sits on a dark #0a0a0f background with two soft radial ambient glows in the brand indigo/violet palette. All text uses system-ui at precise weights (400/600/700/800). A full global fade-out runs over the last 0.5 seconds. Every detail — reviewer name, company, quote, and logo — uses realistic fictional SaaS data and is straightforward to swap via constants at the top of the file.

Typical use cases include social-proof video ads, product landing-page hero sections, newsletter footers rendered as video, and customer-story reels.

Composition specs

PropertyValue
Resolution1280 × 720
FPS30
Duration8 s (240 frames)

Timeline

TimeFrameAction
0.0 s0Card scales in (spring), avatar flies in from top
0.6 s18Reviewer name + role fade + translate up
1.4 s42Five gold stars spring in left-to-right (7 frame stagger)
2.4 s72Divider line sweeps in from left
2.8 s84Quote block expands; left accent bar scales down; words reveal sequentially
6.0 s180Second divider line sweeps in
6.5 s194Company logo pill + verified-customer badge slide up
7.0 s210Watermark fades in
7.5 s225Global fade-out begins
8.0 s240End