StealThis .dev
Remotion Medium

Remotion — Feature Tour Video

A feature tour video built with Remotion that presents five product features in a rotating carousel-style sequence — each feature card enters from the right with its icon, bold heading, and description, stays for 2 seconds, then exits left as the next arrives. A feature number indicator at the top and a progress dots row at the bottom track position. Designed for landing page hero loops and app store preview videos.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

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

// ── Brand / theme ──────────────────────────────────────────────────────────────
const BG       = "#0c0c12";
const SURFACE  = "#13131f";
const CARD     = "#1a1a2a";
const CYAN     = "#06b6d4";
const CYAN_DIM = "#0891b2";
const TEXT     = "#f8fafc";
const MUTED    = "rgba(248,250,252,0.50)";
const SUBTLE   = "rgba(248,250,252,0.08)";

const PRODUCT  = "Nexus CRM";

// ── Feature data ───────────────────────────────────────────────────────────────
interface Feature {
  icon: string;
  title: string;
  tag: string;
  desc: string;
  sub: string;
  accent: string;
}

const FEATURES: Feature[] = [
  {
    icon: "⚡",
    title: "Instant Sync",
    tag: "Real-time",
    desc: "Your data, everywhere, the moment it changes.",
    sub: "Live updates across all devices — zero lag, zero conflicts.",
    accent: CYAN,
  },
  {
    icon: "🔒",
    title: "Enterprise Security",
    tag: "SOC 2 Certified",
    desc: "Bank-grade protection for your entire pipeline.",
    sub: "End-to-end encryption, SSO, audit logs, and role-based access control.",
    accent: "#a78bfa",
  },
  {
    icon: "📊",
    title: "Smart Analytics",
    tag: "AI-Powered",
    desc: "Insights that find you — before you even ask.",
    sub: "Custom dashboards, predictive models, and anomaly detection built in.",
    accent: "#f59e0b",
  },
  {
    icon: "🔗",
    title: "300+ Integrations",
    tag: "One-Click",
    desc: "Connect your entire stack without writing a line of code.",
    sub: "Slack, Salesforce, HubSpot, Stripe, Zapier, and 295 more — ready to go.",
    accent: "#34d399",
  },
  {
    icon: "🎯",
    title: "Goal Tracking",
    tag: "Precision",
    desc: "Set it. Track it. Hit it. Every time.",
    sub: "Visual progress rings, milestone alerts, and team accountability boards.",
    accent: "#fb7185",
  },
];

// Each feature card occupies 60 frames. 5 × 60 = 300 total.
const FRAMES_PER_CARD = 60;
const TOTAL_FRAMES    = 300;

// ── Sub-component: Background layer (dot grid + glows) ─────────────────────────
function Background() {
  return (
    <AbsoluteFill style={{ background: BG, overflow: "hidden" }}>
      {/* Subtle dot grid */}
      <svg
        width="1280"
        height="720"
        style={{ position: "absolute", inset: 0, opacity: 0.35 }}
      >
        <defs>
          <pattern
            id="ftgrid"
            x="0"
            y="0"
            width="40"
            height="40"
            patternUnits="userSpaceOnUse"
          >
            <circle cx="1" cy="1" r="1" fill="rgba(248,250,252,0.25)" />
          </pattern>
        </defs>
        <rect width="1280" height="720" fill="url(#ftgrid)" />
      </svg>

      {/* Radial cyan glow — left */}
      <div
        style={{
          position: "absolute",
          left: -160,
          top: "50%",
          transform: "translateY(-50%)",
          width: 700,
          height: 700,
          borderRadius: "50%",
          background:
            "radial-gradient(circle, rgba(6,182,212,0.12) 0%, transparent 70%)",
          pointerEvents: "none",
        }}
      />

      {/* Radial violet glow — right */}
      <div
        style={{
          position: "absolute",
          right: -200,
          top: "50%",
          transform: "translateY(-50%)",
          width: 600,
          height: 600,
          borderRadius: "50%",
          background:
            "radial-gradient(circle, rgba(167,139,250,0.09) 0%, transparent 70%)",
          pointerEvents: "none",
        }}
      />

      {/* Vignette */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background:
            "radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.65) 100%)",
          pointerEvents: "none",
        }}
      />
    </AbsoluteFill>
  );
}

// ── Sub-component: Top header bar (rendered once in root, always visible) ─────
function HeaderBar() {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const enter = spring({
    frame,
    fps,
    config: { damping: 20, stiffness: 120, mass: 0.7 },
  });

  return (
    <div
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        right: 0,
        height: 72,
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        paddingLeft: 60,
        paddingRight: 60,
        opacity: interpolate(enter, [0, 1], [0, 1]),
        transform: `translateY(${interpolate(enter, [0, 1], [-20, 0])}px)`,
        zIndex: 20,
      }}
    >
      {/* Logo wordmark */}
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <div
          style={{
            width: 32,
            height: 32,
            borderRadius: 8,
            background: `linear-gradient(135deg, ${CYAN}, ${CYAN_DIM})`,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontSize: 16,
            boxShadow: `0 0 16px ${CYAN}55`,
          }}
        >

        </div>
        <span
          style={{
            fontFamily: "system-ui,-apple-system,sans-serif",
            fontSize: 16,
            fontWeight: 700,
            color: TEXT,
            letterSpacing: "-0.01em",
          }}
        >
          {PRODUCT}
        </span>
      </div>

      {/* Label */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          background: SUBTLE,
          border: "1px solid rgba(248,250,252,0.12)",
          borderRadius: 100,
          padding: "6px 16px",
        }}
      >
        <span
          style={{
            fontFamily: "system-ui,-apple-system,sans-serif",
            fontSize: 12,
            fontWeight: 600,
            color: MUTED,
            letterSpacing: "0.06em",
            textTransform: "uppercase",
          }}
        >
          Feature Tour
        </span>
      </div>
    </div>
  );
}

// ── Sub-component: Progress dots (rendered in root, uses global frame) ─────────
function ProgressDots({ globalFrame }: { globalFrame: number }) {
  const { fps } = useVideoConfig();
  const activeIndex = Math.min(
    Math.floor(globalFrame / FRAMES_PER_CARD),
    FEATURES.length - 1
  );

  const enter = spring({
    frame: globalFrame,
    fps,
    config: { damping: 20, stiffness: 140 },
  });

  return (
    <div
      style={{
        position: "absolute",
        bottom: 36,
        left: "50%",
        transform: "translateX(-50%)",
        display: "flex",
        alignItems: "center",
        gap: 10,
        opacity: enter,
        zIndex: 20,
      }}
    >
      {FEATURES.map((feat, i) => {
        const isActive = i === activeIndex;
        return (
          <div
            key={i}
            style={{
              width: isActive ? 28 : 8,
              height: 8,
              borderRadius: 4,
              background: isActive ? feat.accent : "rgba(248,250,252,0.18)",
              boxShadow: isActive ? `0 0 10px ${feat.accent}99` : "none",
            }}
          />
        );
      })}
    </div>
  );
}

// ── Sub-component: Feature number indicator (rendered in root) ─────────────────
function FeatureCounter({ globalFrame }: { globalFrame: number }) {
  const { fps } = useVideoConfig();
  const activeIndex = Math.min(
    Math.floor(globalFrame / FRAMES_PER_CARD),
    FEATURES.length - 1
  );
  const accent = FEATURES[activeIndex].accent;

  // Re-animate on each card change
  const localFrame = globalFrame % FRAMES_PER_CARD;
  const enter = spring({
    frame: localFrame,
    fps,
    config: { damping: 22, stiffness: 160 },
  });

  return (
    <div
      style={{
        position: "absolute",
        top: 88,
        left: 60,
        display: "flex",
        alignItems: "center",
        gap: 10,
        opacity: interpolate(enter, [0, 1], [0, 1]),
        transform: `translateY(${interpolate(enter, [0, 1], [10, 0])}px)`,
        zIndex: 20,
      }}
    >
      <div
        style={{
          width: 32,
          height: 32,
          borderRadius: 8,
          background: `${accent}18`,
          border: `1px solid ${accent}55`,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontFamily: "system-ui,-apple-system,sans-serif",
          fontSize: 13,
          fontWeight: 800,
          color: accent,
          boxShadow: `0 0 12px ${accent}33`,
        }}
      >
        {activeIndex + 1}
      </div>
      <span
        style={{
          fontFamily: "system-ui,-apple-system,sans-serif",
          fontSize: 12,
          fontWeight: 600,
          color: MUTED,
          letterSpacing: "0.06em",
        }}
      >
        of {FEATURES.length}
      </span>
    </div>
  );
}

// ── Sub-component: Feature card — reads its own local frame via useCurrentFrame
function FeatureCardInner({ feature }: { feature: Feature }) {
  const localFrame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Slide in from right over first ~28 frames, then slide out left 44→60
  const HOLD_END      = 44;
  const SLIDE_OUT_END = FRAMES_PER_CARD;

  const slideInProg = spring({
    frame: localFrame,
    fps,
    config: { damping: 18, stiffness: 110, mass: 0.8 },
  });

  const slideOutProg = interpolate(
    localFrame,
    [HOLD_END, SLIDE_OUT_END],
    [0, 1],
    {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.in(Easing.quad),
    }
  );

  const translateX = interpolate(slideInProg, [0, 1], [200, 0]) - slideOutProg * 240;
  const opacity    = interpolate(slideInProg, [0, 1], [0, 1]) * (1 - slideOutProg * 0.75);
  const scale      = interpolate(slideInProg, [0, 1], [0.92, 1]);

  // Staggered inner element entrances
  const tagEnter  = spring({ frame: Math.max(0, localFrame - 6),  fps, config: { damping: 22, stiffness: 160 } });
  const iconEnter = spring({ frame: Math.max(0, localFrame - 10), fps, config: { damping: 18, stiffness: 120 } });
  const headEnter = spring({ frame: Math.max(0, localFrame - 14), fps, config: { damping: 20, stiffness: 130 } });
  const descEnter = spring({ frame: Math.max(0, localFrame - 20), fps, config: { damping: 22, stiffness: 140 } });
  const subEnter  = spring({ frame: Math.max(0, localFrame - 25), fps, config: { damping: 22, stiffness: 140 } });
  const linkEnter = spring({ frame: Math.max(0, localFrame - 30), fps, config: { damping: 22, stiffness: 150 } });

  const { accent } = feature;

  return (
    <div
      style={{
        position: "absolute",
        top: "50%",
        left: "50%",
        transform: `translate(-50%, -50%) translateX(${translateX}px) scale(${scale})`,
        opacity,
        width: 660,
        background: CARD,
        border: `1px solid ${accent}33`,
        borderRadius: 20,
        padding: "48px 56px 44px",
        boxShadow: `0 32px 80px rgba(0,0,0,0.65), 0 0 60px ${accent}18`,
      }}
    >
      {/* Accent stripe */}
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 56,
          right: 56,
          height: 2,
          background: `linear-gradient(90deg, ${accent}, transparent)`,
          borderRadius: "0 0 2px 2px",
        }}
      />

      {/* Tag pill */}
      <div
        style={{
          display: "inline-flex",
          alignItems: "center",
          gap: 6,
          background: `${accent}15`,
          border: `1px solid ${accent}44`,
          borderRadius: 100,
          padding: "5px 14px",
          marginBottom: 24,
          opacity: tagEnter,
          transform: `translateY(${interpolate(tagEnter, [0, 1], [8, 0])}px)`,
        }}
      >
        <div
          style={{
            width: 6,
            height: 6,
            borderRadius: "50%",
            background: accent,
            boxShadow: `0 0 8px ${accent}`,
          }}
        />
        <span
          style={{
            fontFamily: "system-ui,-apple-system,sans-serif",
            fontSize: 11,
            fontWeight: 700,
            color: accent,
            letterSpacing: "0.06em",
            textTransform: "uppercase",
          }}
        >
          {feature.tag}
        </span>
      </div>

      {/* Icon */}
      <div
        style={{
          fontSize: 80,
          lineHeight: 1,
          marginBottom: 20,
          opacity: iconEnter,
          transform: `translateY(${interpolate(iconEnter, [0, 1], [16, 0])}px) scale(${interpolate(iconEnter, [0, 1], [0.65, 1])})`,
          filter: `drop-shadow(0 8px 24px ${accent}55)`,
          display: "block",
        }}
      >
        {feature.icon}
      </div>

      {/* Title */}
      <h2
        style={{
          margin: "0 0 12px",
          fontFamily: "system-ui,-apple-system,sans-serif",
          fontSize: 48,
          fontWeight: 800,
          color: TEXT,
          lineHeight: 1.05,
          letterSpacing: "-0.02em",
          opacity: headEnter,
          transform: `translateY(${interpolate(headEnter, [0, 1], [14, 0])}px)`,
        }}
      >
        {feature.title}
      </h2>

      {/* Primary description */}
      <p
        style={{
          margin: "0 0 8px",
          fontFamily: "system-ui,-apple-system,sans-serif",
          fontSize: 22,
          fontWeight: 500,
          color: TEXT,
          lineHeight: 1.4,
          opacity: descEnter,
          transform: `translateY(${interpolate(descEnter, [0, 1], [10, 0])}px)`,
        }}
      >
        {feature.desc}
      </p>

      {/* Sub-description */}
      <p
        style={{
          margin: "0 0 28px",
          fontFamily: "system-ui,-apple-system,sans-serif",
          fontSize: 15,
          fontWeight: 400,
          color: MUTED,
          lineHeight: 1.65,
          opacity: subEnter,
          transform: `translateY(${interpolate(subEnter, [0, 1], [8, 0])}px)`,
        }}
      >
        {feature.sub}
      </p>

      {/* Ghost CTA */}
      <div
        style={{
          display: "inline-flex",
          alignItems: "center",
          gap: 6,
          padding: "10px 22px",
          borderRadius: 10,
          border: `1px solid ${accent}55`,
          background: `${accent}0d`,
          opacity: linkEnter,
          transform: `translateY(${interpolate(linkEnter, [0, 1], [8, 0])}px)`,
        }}
      >
        <span
          style={{
            fontFamily: "system-ui,-apple-system,sans-serif",
            fontSize: 14,
            fontWeight: 600,
            color: accent,
          }}
        >
          Learn more →
        </span>
      </div>
    </div>
  );
}

// ── Sub-component: Right-side stat panel — reads its own local frame ──────────
interface StatItem {
  value: string;
  label: string;
}

const FEATURE_STATS: StatItem[][] = [
  [
    { value: "< 50ms", label: "Sync latency" },
    { value: "99.99%", label: "Uptime SLA" },
    { value: "12",     label: "Devices per user" },
  ],
  [
    { value: "SOC 2",  label: "Type II certified" },
    { value: "256-bit",label: "AES encryption" },
    { value: "GDPR",   label: "Compliant" },
  ],
  [
    { value: "40+",    label: "Report templates" },
    { value: "Live",   label: "Data refresh" },
    { value: "AI",     label: "Anomaly alerts" },
  ],
  [
    { value: "300+",   label: "Native connectors" },
    { value: "1-click",label: "OAuth setup" },
    { value: "REST",   label: "Webhook support" },
  ],
  [
    { value: "OKR",    label: "Framework built-in" },
    { value: "Daily",  label: "Check-in reminders" },
    { value: "Team",   label: "Leaderboards" },
  ],
];

function StatPanelInner({ featureIndex }: { featureIndex: number }) {
  const localFrame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const stats = FEATURE_STATS[featureIndex];
  const accent = FEATURES[featureIndex].accent;

  const panelEnter = spring({
    frame: Math.max(0, localFrame - 14),
    fps,
    config: { damping: 20, stiffness: 120, mass: 0.8 },
  });

  return (
    <div
      style={{
        position: "absolute",
        right: 60,
        top: "50%",
        transform: `translateY(-50%) translateX(${interpolate(panelEnter, [0, 1], [50, 0])}px)`,
        opacity: interpolate(panelEnter, [0, 1], [0, 1]),
        display: "flex",
        flexDirection: "column",
        gap: 12,
        width: 190,
      }}
    >
      {stats.map((stat, i) => {
        const itemEnter = spring({
          frame: Math.max(0, localFrame - 18 - i * 6),
          fps,
          config: { damping: 22, stiffness: 140 },
        });
        return (
          <div
            key={i}
            style={{
              background: SURFACE,
              border: `1px solid ${accent}22`,
              borderLeft: `3px solid ${accent}`,
              borderRadius: 10,
              padding: "12px 16px",
              opacity: interpolate(itemEnter, [0, 1], [0, 1]),
              transform: `translateX(${interpolate(itemEnter, [0, 1], [24, 0])}px)`,
              boxShadow: `0 4px 20px rgba(0,0,0,0.4)`,
            }}
          >
            <div
              style={{
                fontFamily: "system-ui,-apple-system,sans-serif",
                fontSize: 22,
                fontWeight: 800,
                color: accent,
                lineHeight: 1,
                marginBottom: 4,
                letterSpacing: "-0.01em",
              }}
            >
              {stat.value}
            </div>
            <div
              style={{
                fontFamily: "system-ui,-apple-system,sans-serif",
                fontSize: 11,
                fontWeight: 500,
                color: MUTED,
                letterSpacing: "0.03em",
              }}
            >
              {stat.label}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ── Root composition ───────────────────────────────────────────────────────────
function FeatureTourVideo() {
  const frame = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();

  // Global fade-in / fade-out
  const globalOpacity = interpolate(
    frame,
    [0, 8, durationInFrames - 12, durationInFrames],
    [0, 1, 1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  return (
    <AbsoluteFill style={{ overflow: "hidden", opacity: globalOpacity }}>
      {/* Persistent background */}
      <Background />

      {/* Persistent header (frame 0 of overall video) */}
      <HeaderBar />

      {/* Feature number indicator — re-animates per card */}
      <FeatureCounter globalFrame={frame} />

      {/* Feature cards — one Sequence per feature */}
      {FEATURES.map((feature, i) => (
        <Sequence
          key={i}
          from={i * FRAMES_PER_CARD}
          durationInFrames={FRAMES_PER_CARD}
        >
          <FeatureCardInner feature={feature} />
          <StatPanelInner featureIndex={i} />
        </Sequence>
      ))}

      {/* Progress dots */}
      <ProgressDots globalFrame={frame} />
    </AbsoluteFill>
  );
}

// ── RemotionRoot (required entry point) ───────────────────────────────────────
export function RemotionRoot() {
  return (
    <Composition
      id="FeatureTourVideo"
      component={FeatureTourVideo}
      durationInFrames={TOTAL_FRAMES}
      fps={30}
      width={1280}
      height={720}
    />
  );
}

export default FeatureTourVideo;

Feature Tour Video

A feature tour carousel: five feature cards cycle in sequence using Sequence components. Each card enters from the right (translateX 100→0), holds center, then exits left (translateX 0→-100). Cards show: large icon (emoji or Unicode symbol, 80px), bold feature title (48px), description (2 lines, muted), and a “Learn more →” ghost link. Progress dots at bottom highlight the active feature.

Composition specs

PropertyValue
Resolution1280 × 720
FPS30
Duration10 s (300 frames)

Usage

Copy react.tsx into your Remotion project, import RemotionRoot in your Root.tsx, and run npx remotion studio to preview.

Illustrative animation only — fictional data and content.