StealThis .dev
Remotion Medium

Quiz Short Video (Remotion)

A vertical quiz short with countdown timer, multiple choice options revealing one by one, and an animated correct answer reveal — 1080×1920, 30 fps.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

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

// ── CONFIG ────────────────────────────────────────────────────────────────────
const DURATION = 450; // 15 s at 30 fps

// Phases (in frames)
const PHASE_QUESTION_END = 60;
const PHASE_OPTIONS_START = 60;
const PHASE_OPTIONS_END = 240;
const PHASE_COUNTDOWN_START = 240;
const PHASE_COUNTDOWN_END = 300;
const PHASE_REVEAL_START = 300;
const PHASE_REVEAL_END = 420;

// Content
const QUESTION = "What does 'typeof null' return in JavaScript?";
const OPTIONS: { label: string; text: string; correct: boolean }[] = [
  { label: "A", text: '"null"', correct: false },
  { label: "B", text: '"undefined"', correct: false },
  { label: "C", text: '"object"', correct: true },
  { label: "D", text: '"string"', correct: false },
];
const CORRECT_INDEX = 2; // C

// Colours
const BG_TOP = "#1a0a2e";
const BG_BOTTOM = "#0d0620";
const ACCENT_PURPLE = "#a855f7";
const ACCENT_GREEN = "#22c55e";
const PILL_BORDER = "rgba(255,255,255,0.35)";
const PILL_BG = "rgba(255,255,255,0.06)";
const PILL_CORRECT_BG = "rgba(34,197,94,0.18)";
const TEXT_PRIMARY = "#ffffff";
const TEXT_MUTED = "rgba(255,255,255,0.55)";

const FONT = "system-ui, -apple-system, 'Helvetica Neue', sans-serif";

// Confetti dot colours
const CONFETTI_COLORS = [
  "#f472b6",
  "#a855f7",
  "#60a5fa",
  "#34d399",
  "#fbbf24",
  "#fb923c",
  "#f87171",
  "#38bdf8",
  "#4ade80",
  "#e879f9",
];

// ── Dot-grid overlay ──────────────────────────────────────────────────────────
const DotGrid: React.FC = () => (
  <div
    style={{
      position: "absolute",
      inset: 0,
      backgroundImage:
        "radial-gradient(circle, rgba(168,85,247,0.10) 1px, transparent 1px)",
      backgroundSize: "48px 48px",
      pointerEvents: "none",
    }}
  />
);

// ── Timer bar at top ──────────────────────────────────────────────────────────
const TimerBar: React.FC<{ frame: number }> = ({ frame }) => {
  // Only visible during options phase
  const opacity = interpolate(
    frame,
    [PHASE_OPTIONS_START, PHASE_OPTIONS_START + 10, PHASE_OPTIONS_END - 10, PHASE_OPTIONS_END],
    [0, 1, 1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  const progress = interpolate(
    frame,
    [PHASE_OPTIONS_START, PHASE_OPTIONS_END],
    [1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  // Color transitions from purple → yellow → red as timer runs down
  const r = Math.round(interpolate(progress, [0, 0.4, 1], [239, 251, 168], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
  const g = Math.round(interpolate(progress, [0, 0.4, 1], [68, 191, 85], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
  const b = Math.round(interpolate(progress, [0, 0.4, 1], [68, 36, 247], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));

  return (
    <div
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        right: 0,
        height: 8,
        backgroundColor: "rgba(255,255,255,0.10)",
        opacity,
      }}
    >
      <div
        style={{
          width: `${progress * 100}%`,
          height: "100%",
          backgroundColor: `rgb(${r},${g},${b})`,
          boxShadow: `0 0 12px rgb(${r},${g},${b})`,
          transition: "none",
        }}
      />
    </div>
  );
};

// ── Question card ─────────────────────────────────────────────────────────────
const QuestionCard: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
  const translateY = spring({
    frame,
    fps,
    from: -120,
    to: 0,
    config: { damping: 18, stiffness: 140, mass: 0.8 },
  });
  const opacity = interpolate(frame, [0, 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        position: "absolute",
        top: 160,
        left: 48,
        right: 48,
        opacity,
        transform: `translateY(${translateY}px)`,
      }}
    >
      {/* Quiz label */}
      <div
        style={{
          fontFamily: FONT,
          fontWeight: 700,
          fontSize: 20,
          letterSpacing: 4,
          textTransform: "uppercase" as const,
          color: ACCENT_PURPLE,
          marginBottom: 24,
          textShadow: `0 0 20px ${ACCENT_PURPLE}`,
        }}
      >
        ⚡ JS Quiz
      </div>

      {/* Question */}
      <div
        style={{
          backgroundColor: "rgba(168,85,247,0.10)",
          border: "1.5px solid rgba(168,85,247,0.35)",
          borderRadius: 24,
          padding: "36px 40px",
        }}
      >
        <div
          style={{
            fontFamily: FONT,
            fontWeight: 800,
            fontSize: 46,
            lineHeight: 1.2,
            color: TEXT_PRIMARY,
            letterSpacing: -0.5,
          }}
        >
          {QUESTION}
        </div>
      </div>
    </div>
  );
};

// ── Single answer option pill ─────────────────────────────────────────────────
const OptionPill: React.FC<{
  frame: number;
  fps: number;
  index: number;
  label: string;
  text: string;
  isCorrect: boolean;
  revealCorrect: boolean;
}> = ({ frame, fps, index, label, text, isCorrect, revealCorrect }) => {
  // Stagger each option 30 frames apart starting at PHASE_OPTIONS_START
  const startFrame = PHASE_OPTIONS_START + index * 30;
  const f = Math.max(0, frame - startFrame);

  const translateX = spring({
    frame: f,
    fps,
    from: -160,
    to: 0,
    config: { damping: 16, stiffness: 130, mass: 0.7 },
  });
  const opacity = interpolate(f, [0, 14], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Correct answer highlight after reveal phase starts
  const highlightProgress = interpolate(
    frame,
    [PHASE_COUNTDOWN_END, PHASE_COUNTDOWN_END + 8],
    [0, 1],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );
  const showHighlight = revealCorrect && isCorrect;
  const dimWrong = revealCorrect && !isCorrect;

  const dimOpacity = dimWrong
    ? interpolate(frame, [PHASE_COUNTDOWN_END, PHASE_COUNTDOWN_END + 15], [1, 0.25], {
        extrapolateLeft: "clamp",
        extrapolateRight: "clamp",
      })
    : 1;

  const borderColor = showHighlight
    ? `rgba(34,197,94,${highlightProgress})`
    : PILL_BORDER;

  const bgColor = showHighlight ? PILL_CORRECT_BG : PILL_BG;

  return (
    <div
      style={{
        opacity: opacity * dimOpacity,
        transform: `translateX(${translateX}px)`,
        display: "flex",
        alignItems: "center",
        gap: 20,
        backgroundColor: bgColor,
        border: `2px solid ${borderColor}`,
        borderRadius: 60,
        paddingTop: 22,
        paddingBottom: 22,
        paddingLeft: 28,
        paddingRight: 36,
        boxShadow: showHighlight
          ? `0 0 30px rgba(34,197,94,${highlightProgress * 0.5})`
          : "none",
      }}
    >
      {/* Letter badge */}
      <div
        style={{
          width: 52,
          height: 52,
          borderRadius: "50%",
          backgroundColor: showHighlight
            ? `rgba(34,197,94,${0.3 * highlightProgress})`
            : "rgba(168,85,247,0.20)",
          border: `2px solid ${showHighlight ? `rgba(34,197,94,${highlightProgress})` : ACCENT_PURPLE}`,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexShrink: 0,
        }}
      >
        <span
          style={{
            fontFamily: FONT,
            fontWeight: 800,
            fontSize: 22,
            color: showHighlight ? ACCENT_GREEN : ACCENT_PURPLE,
          }}
        >
          {label}
        </span>
      </div>

      {/* Answer text */}
      <span
        style={{
          fontFamily: FONT,
          fontWeight: 600,
          fontSize: 36,
          color: TEXT_PRIMARY,
          letterSpacing: 0.2,
        }}
      >
        {text}
      </span>

      {/* Checkmark for correct */}
      {showHighlight && (
        <span
          style={{
            marginLeft: "auto",
            fontSize: 38,
            opacity: highlightProgress,
          }}
        >

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

// ── Options list ──────────────────────────────────────────────────────────────
const OptionsList: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
  const revealCorrect = frame >= PHASE_COUNTDOWN_END;

  return (
    <div
      style={{
        position: "absolute",
        top: 580,
        left: 48,
        right: 48,
        display: "flex",
        flexDirection: "column",
        gap: 24,
      }}
    >
      {OPTIONS.map((opt, i) => (
        <OptionPill
          key={opt.label}
          frame={frame}
          fps={fps}
          index={i}
          label={opt.label}
          text={opt.text}
          isCorrect={opt.correct}
          revealCorrect={revealCorrect}
        />
      ))}
    </div>
  );
};

// ── Countdown ring (SVG) ──────────────────────────────────────────────────────
const CountdownRing: React.FC<{ frame: number }> = ({ frame }) => {
  if (frame < PHASE_COUNTDOWN_START || frame > PHASE_COUNTDOWN_END) return null;

  const opacity = interpolate(
    frame,
    [PHASE_COUNTDOWN_START, PHASE_COUNTDOWN_START + 8, PHASE_COUNTDOWN_END - 6, PHASE_COUNTDOWN_END],
    [0, 1, 1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  const progress = interpolate(
    frame,
    [PHASE_COUNTDOWN_START, PHASE_COUNTDOWN_END],
    [1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  const radius = 80;
  const circumference = 2 * Math.PI * radius;
  const dashOffset = circumference * (1 - progress);

  // Position the ring over option C (index 2)
  // Options start at top:580, each ~98px tall + 24px gap
  // Option C is at index 2 → 580 + 2*(98+24) = 580 + 244 = 824, center ~873
  const ringCenterY = 873;
  const ringCenterX = 540; // horizontal center of 1080px

  return (
    <div
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        width: "100%",
        height: "100%",
        opacity,
        pointerEvents: "none",
      }}
    >
      <svg
        width="1080"
        height="1920"
        viewBox="0 0 1080 1920"
        style={{ position: "absolute", top: 0, left: 0 }}
      >
        {/* Track */}
        <circle
          cx={ringCenterX}
          cy={ringCenterY}
          r={radius}
          fill="none"
          stroke="rgba(255,255,255,0.10)"
          strokeWidth={8}
        />
        {/* Progress arc */}
        <circle
          cx={ringCenterX}
          cy={ringCenterY}
          r={radius}
          fill="none"
          stroke={ACCENT_PURPLE}
          strokeWidth={8}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={dashOffset}
          transform={`rotate(-90 ${ringCenterX} ${ringCenterY})`}
          style={{ filter: `drop-shadow(0 0 10px ${ACCENT_PURPLE})` }}
        />
      </svg>
    </div>
  );
};

// ── Green flash overlay ───────────────────────────────────────────────────────
const GreenFlash: React.FC<{ frame: number }> = ({ frame }) => {
  const flashOpacity = interpolate(
    frame,
    [PHASE_COUNTDOWN_END - 4, PHASE_COUNTDOWN_END, PHASE_COUNTDOWN_END + 12, PHASE_COUNTDOWN_END + 24],
    [0, 0.45, 0.45, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  if (flashOpacity <= 0) return null;

  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        backgroundColor: ACCENT_GREEN,
        opacity: flashOpacity,
        pointerEvents: "none",
      }}
    />
  );
};

// ── Correct banner ────────────────────────────────────────────────────────────
const CorrectBanner: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
  const f = Math.max(0, frame - PHASE_REVEAL_START);
  const translateY = spring({
    frame: f,
    fps,
    from: 200,
    to: 0,
    config: { damping: 16, stiffness: 140, mass: 0.8 },
  });
  const opacity = interpolate(f, [0, 12], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  if (frame < PHASE_REVEAL_START) return null;

  return (
    <div
      style={{
        position: "absolute",
        bottom: 180,
        left: 48,
        right: 48,
        opacity,
        transform: `translateY(${translateY}px)`,
      }}
    >
      <div
        style={{
          backgroundColor: ACCENT_GREEN,
          borderRadius: 28,
          paddingTop: 32,
          paddingBottom: 32,
          paddingLeft: 44,
          paddingRight: 44,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: 18,
          boxShadow: `0 0 60px rgba(34,197,94,0.55)`,
        }}
      >
        <span style={{ fontSize: 52 }}>✓</span>
        <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
          <div
            style={{
              fontFamily: FONT,
              fontWeight: 700,
              fontSize: 22,
              color: "rgba(0,0,0,0.65)",
              textTransform: "uppercase" as const,
              letterSpacing: 2,
            }}
          >
            Correct!
          </div>
          <div
            style={{
              fontFamily: FONT,
              fontWeight: 900,
              fontSize: 42,
              color: "#000000",
              letterSpacing: -0.5,
            }}
          >
            typeof null === "object"
          </div>
        </div>
      </div>
    </div>
  );
};

// ── Confetti dots ─────────────────────────────────────────────────────────────
interface ConfettiDot {
  id: number;
  color: string;
  angle: number; // radians
  distance: number; // max px from center
  size: number;
  delay: number;
}

// Pre-generate 20 dots deterministically (no Math.random at render time)
const CONFETTI_DOTS: ConfettiDot[] = Array.from({ length: 20 }, (_, i) => {
  const golden = 2.399963; // golden angle in radians
  return {
    id: i,
    color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
    angle: i * golden,
    distance: 200 + (i % 5) * 80,
    size: 16 + (i % 4) * 8,
    delay: Math.floor(i * 2.5), // stagger 0-47 frames
  };
});

const ConfettiBurst: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
  if (frame < PHASE_REVEAL_START) return null;

  // Center of composition
  const cx = 540;
  const cy = 960;

  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        pointerEvents: "none",
        overflow: "hidden",
      }}
    >
      {CONFETTI_DOTS.map((dot) => {
        const f = Math.max(0, frame - PHASE_REVEAL_START - dot.delay);

        const dist = spring({
          frame: f,
          fps,
          from: 0,
          to: dot.distance,
          config: { damping: 12, stiffness: 100, mass: 0.6 },
        });

        const opacity = interpolate(f, [0, 6, 50, 80], [0, 1, 1, 0], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });

        const x = cx + Math.cos(dot.angle) * dist - dot.size / 2;
        const y = cy + Math.sin(dot.angle) * dist - dot.size / 2;

        const rotate = interpolate(f, [0, 80], [0, 360 * (dot.id % 2 === 0 ? 1 : -1)], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });

        return (
          <div
            key={dot.id}
            style={{
              position: "absolute",
              left: x,
              top: y,
              width: dot.size,
              height: dot.size,
              borderRadius: dot.id % 3 === 0 ? "50%" : 4,
              backgroundColor: dot.color,
              opacity,
              transform: `rotate(${rotate}deg)`,
              boxShadow: `0 0 8px ${dot.color}`,
            }}
          />
        );
      })}
    </div>
  );
};

// ── Phase label (bottom hint) ─────────────────────────────────────────────────
const PhaseHint: React.FC<{ frame: number }> = ({ frame }) => {
  const inOptions =
    frame >= PHASE_OPTIONS_START && frame < PHASE_COUNTDOWN_START;
  const inCountdown =
    frame >= PHASE_COUNTDOWN_START && frame < PHASE_COUNTDOWN_END;

  let label = "";
  if (inOptions) {
    const secondsLeft = Math.ceil(
      ((PHASE_COUNTDOWN_START - frame) / 30)
    );
    label = `⏱ Think fast… ${secondsLeft}s`;
  } else if (inCountdown) {
    label = "🔍 Revealing answer…";
  }

  if (!label) return null;

  const opacity = interpolate(frame, [PHASE_OPTIONS_START, PHASE_OPTIONS_START + 10], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        position: "absolute",
        bottom: 80,
        left: 0,
        right: 0,
        textAlign: "center" as const,
        opacity,
        fontFamily: FONT,
        fontWeight: 600,
        fontSize: 28,
        color: TEXT_MUTED,
        letterSpacing: 0.5,
      }}
    >
      {label}
    </div>
  );
};

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

  return (
    <AbsoluteFill
      style={{
        background: `radial-gradient(ellipse at 50% 30%, #2d1050 0%, ${BG_TOP} 45%, ${BG_BOTTOM} 100%)`,
        overflow: "hidden",
      }}
    >
      <DotGrid />

      {/* Timer bar (top) */}
      <TimerBar frame={frame} />

      {/* Question */}
      <QuestionCard frame={frame} fps={fps} />

      {/* Answer options */}
      <OptionsList frame={frame} fps={fps} />

      {/* Countdown ring over option C */}
      <CountdownRing frame={frame} />

      {/* Green flash */}
      <GreenFlash frame={frame} />

      {/* Confetti */}
      <ConfettiBurst frame={frame} fps={fps} />

      {/* Correct banner */}
      <CorrectBanner frame={frame} fps={fps} />

      {/* Phase hint */}
      <PhaseHint frame={frame} />
    </AbsoluteFill>
  );
};

// ── Remotion Root ─────────────────────────────────────────────────────────────
export const RemotionRoot: React.FC = () => (
  <Composition
    id="QuizShort"
    component={QuizShort}
    durationInFrames={DURATION}
    fps={30}
    width={1080}
    height={1920}
  />
);

Quiz Short Video

An interactive-style quiz short designed for social media reels and YouTube Shorts. The composition opens with a JavaScript trivia question sliding in from the top, then reveals four multiple-choice answer options one by one with spring animations. A countdown timer bar at the top ticks down while viewers “think,” followed by a dramatic countdown ring pulsing around the correct answer before a green flash confirms it. The finale drops a “CORRECT: object” banner onto the screen while 20 colorful confetti dots spring outward from center — ideal for dev-education creators, coding bootcamps, or any brand running engagement-driven quiz content on vertical platforms.

Composition specs

PropertyValue
Resolution1080×1920
FPS30
Duration450 frames (15 s)

Elements

  • Dark purple radial-gradient background with subtle dot-grid texture overlay
  • Question card: slides in from the top with spring animation (frames 0–60)
  • Timer bar: progress strip at the top counting down during the answer phase (frames 60–240)
  • Four answer pill buttons (A–D) sliding in with staggered spring entrances (frames 60–240)
  • Countdown ring: SVG stroke-dashoffset ring animating around option C (frames 240–300)
  • Green flash overlay: full-screen flash confirming the correct answer (frames 295–310)
  • Correct-answer banner: ”✓ CORRECT: object” slides up from the bottom (frames 300–420)
  • Confetti burst: 20 colored dots spring out radially from the center (frames 300–380)