Remotion — Playlist Promo Video
A 7-second playlist promo animation revealing six track cards one by one from the left, each styled with a glowing purple gradient border, track number, artist, and runtime — topped by a bold MIDNIGHT VIBES header, a 20-bar ambient visualizer pulsing with layered sine waves, and closing with a spring-loaded LISTEN NOW call-to-action button and a streaming-platform logo row fading in at the bottom.
Preview
Code
import React from "react";
import {
AbsoluteFill,
Composition,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
// ── Palette & Config ──────────────────────────────────────────────────────────
const BG = "#0a0a0f";
const SURFACE = "#12121a";
const SURFACE_2 = "#1e1e2e";
const ACCENT = "#a855f7";
const ACCENT_2 = "#06b6d4";
const ACCENT_3 = "#ec4899";
const TEXT = "#f1f5f9";
const MUTED = "#94a3b8";
const PLAYLIST_TITLE = "MIDNIGHT VIBES";
const PLAYLIST_META = "12 tracks · 48 min";
const BAR_COUNT = 20;
// ── Track Data ────────────────────────────────────────────────────────────────
interface Track {
num: number;
title: string;
artist: string;
duration: string;
}
const TRACKS: Track[] = [
{ num: 1, title: "After Dark", artist: "Neon Pulse", duration: "3:42" },
{ num: 2, title: "Velvet Underground", artist: "Luna Hex", duration: "4:15" },
{ num: 3, title: "Crimson Hour", artist: "The Fade", duration: "3:58" },
{ num: 4, title: "Starfall", artist: "Aurora Drift", duration: "5:01" },
{ num: 5, title: "Phantom Circuit", artist: "Synthwave Rebels", duration: "4:33" },
{ num: 6, title: "Neon Cathedral", artist: "Echo Bloom", duration: "3:27" },
];
// Each track slides in 25 frames apart
const TRACK_STAGGER = 25; // frames between each track card reveal
const FIRST_TRACK_START = 40; // frame when first track starts sliding in
const SLIDE_DURATION = 18; // frames to complete the slide animation
const CTA_START_FRAME = 185; // frame when CTA button pulses in
// ── Simulated Audio Visualizer ────────────────────────────────────────────────
// Multiple stacked sine waves for organic feel
function simulateBarHeight(barIndex: number, frame: number, totalBars: number): number {
const t = frame * 0.035;
const normalizedPos = barIndex / totalBars;
// Layer 1: slow rolling wave across bars
const wave1 = Math.sin(normalizedPos * Math.PI * 2.8 + t * 1.2) * 0.30;
// Layer 2: faster secondary oscillation
const wave2 = Math.sin(normalizedPos * Math.PI * 5.4 + t * 2.1 + 1.3) * 0.18;
// Layer 3: high-freq shimmer per bar
const wave3 = Math.sin(barIndex * 3.7 + t * 3.5 + 0.8) * 0.12;
// Layer 4: slow breathing pulse (global amplitude)
const pulse = 0.55 + Math.sin(t * 0.7) * 0.2;
const raw = (0.25 + wave1 + wave2 + wave3) * pulse;
// Clamp between 0.08 and 0.92
return Math.max(0.08, Math.min(0.92, raw));
}
// ── Background Hue Wash ───────────────────────────────────────────────────────
// Slowly shifts HSL hue value over time
function bgHueShift(frame: number): string {
// Cycles from hue 270 (purple) toward 200 (cyan) and back
const hue = 270 + Math.sin(frame * 0.012) * 35;
return `hsl(${hue}, 60%, 8%)`;
}
// ── VU Bars Component ─────────────────────────────────────────────────────────
const VisualizerBars: React.FC<{ frame: number; width: number; height: number }> = ({
frame,
width,
height,
}) => {
const barW = Math.floor(width / BAR_COUNT) - 3;
const maxBarH = height;
return (
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 3,
width,
height,
overflow: "hidden",
}}
>
{Array.from({ length: BAR_COUNT }).map((_, i) => {
const fraction = simulateBarHeight(i, frame, BAR_COUNT);
const barH = fraction * maxBarH;
// Color gradient from accent to accent_2 across bars
const t = i / (BAR_COUNT - 1);
const r1 = 168, g1 = 85, b1 = 247; // #a855f7
const r2 = 6, g2 = 182, b2 = 212; // #06b6d4
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
const color = `rgb(${r},${g},${b})`;
return (
<div
key={i}
style={{
width: barW,
height: barH,
borderRadius: "3px 3px 0 0",
background: `linear-gradient(180deg, ${color} 0%, ${color}88 100%)`,
boxShadow: `0 0 8px ${color}66`,
flexShrink: 0,
}}
/>
);
})}
</div>
);
};
// ── Header Block ──────────────────────────────────────────────────────────────
const HeaderBlock: React.FC<{ frame: number; fps: number; width: number }> = ({
frame,
fps,
width,
}) => {
const titleOpacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const titleY = spring({
frame,
fps,
from: -30,
to: 0,
config: { damping: 18, stiffness: 120 },
});
const metaOpacity = interpolate(frame, [15, 35], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const vizOpacity = interpolate(frame, [20, 40], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Purple pill badge
const badgeScale = spring({
frame: Math.max(0, frame - 5),
fps,
from: 0,
to: 1,
config: { damping: 14, stiffness: 180 },
});
return (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "32%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingTop: 24,
}}
>
{/* "NOW PLAYING" badge */}
<div
style={{
transform: `scale(${badgeScale})`,
marginBottom: 18,
background: "linear-gradient(135deg, #a855f7, #ec4899)",
borderRadius: 100,
padding: "6px 20px",
boxShadow: "0 0 24px rgba(168,85,247,0.5)",
}}
>
<span
style={{
fontFamily: "Inter, sans-serif",
fontWeight: 700,
fontSize: 13,
color: "#fff",
letterSpacing: "2.5px",
textTransform: "uppercase",
}}
>
NOW PLAYING
</span>
</div>
{/* Playlist title */}
<div
style={{
opacity: titleOpacity,
transform: `translateY(${titleY}px)`,
fontFamily: "Inter, sans-serif",
fontWeight: 900,
fontSize: 72,
color: TEXT,
letterSpacing: "-2px",
lineHeight: 1,
textAlign: "center",
background: "linear-gradient(135deg, #f1f5f9 0%, #a855f7 60%, #ec4899 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{PLAYLIST_TITLE}
</div>
{/* Metadata row */}
<div
style={{
opacity: metaOpacity,
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<span
style={{
fontFamily: "Inter, sans-serif",
fontWeight: 500,
fontSize: 18,
color: MUTED,
letterSpacing: "0.5px",
}}
>
{PLAYLIST_META}
</span>
</div>
{/* Visualizer bars */}
<div
style={{
opacity: vizOpacity,
marginTop: 22,
width: Math.min(width * 0.5, 600),
height: 48,
}}
>
<VisualizerBars frame={frame} width={Math.min(width * 0.5, 600)} height={48} />
</div>
</div>
);
};
// ── Track Card ────────────────────────────────────────────────────────────────
interface TrackCardProps {
track: Track;
trackIndex: number;
frame: number;
fps: number;
isNewest: boolean;
}
const TrackCard: React.FC<TrackCardProps> = ({ track, trackIndex, frame, fps, isNewest }) => {
const startFrame = FIRST_TRACK_START + trackIndex * TRACK_STAGGER;
const localFrame = Math.max(0, frame - startFrame);
// Slide in from left
const slideX = interpolate(localFrame, [0, SLIDE_DURATION], [-380, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const cardOpacity = interpolate(localFrame, [0, 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// "Newly added" highlight: scale up briefly then settle
const highlightFrame = Math.max(0, localFrame - SLIDE_DURATION);
const highlightScale = isNewest
? spring({
frame: highlightFrame,
fps,
from: 1.0,
to: 1.02,
config: { damping: 10, stiffness: 300 },
})
: 1;
// Brightness boost for the newest card, then settles to 1
const brightnessBoost = isNewest
? interpolate(highlightFrame, [0, 8, 20], [1, 1.35, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 1;
// Glow on newest card
const glowOpacity = isNewest
? interpolate(highlightFrame, [0, 5, 20], [0, 1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 0;
// Don't render before start
if (localFrame === 0 && frame < startFrame) return null;
return (
<div
style={{
transform: `translateX(${slideX}px) scale(${highlightScale})`,
opacity: cardOpacity,
filter: `brightness(${brightnessBoost})`,
display: "flex",
alignItems: "center",
gap: 16,
padding: "14px 20px",
borderRadius: 12,
backgroundColor: SURFACE,
border: `1px solid ${isNewest ? "rgba(168,85,247,0.4)" : "rgba(255,255,255,0.06)"}`,
borderLeft: "none",
position: "relative",
boxShadow: isNewest
? `0 0 28px rgba(168,85,247,${glowOpacity * 0.45}), 0 2px 12px rgba(0,0,0,0.4)`
: "0 2px 8px rgba(0,0,0,0.3)",
overflow: "hidden",
}}
>
{/* Left gradient accent border */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 4,
background: "linear-gradient(180deg, #a855f7, #ec4899)",
borderRadius: "12px 0 0 12px",
boxShadow: `0 0 12px rgba(168,85,247,0.6)`,
}}
/>
{/* Track number */}
<div
style={{
width: 28,
flexShrink: 0,
marginLeft: 8,
fontFamily: "Inter, sans-serif",
fontWeight: 700,
fontSize: 15,
color: isNewest ? ACCENT : "rgba(255,255,255,0.25)",
textAlign: "center",
}}
>
{String(track.num).padStart(2, "0")}
</div>
{/* Play icon circle */}
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
backgroundColor: isNewest ? ACCENT : "rgba(255,255,255,0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
boxShadow: isNewest ? `0 0 16px rgba(168,85,247,0.7)` : "none",
transition: "background-color 0.1s",
}}
>
{/* SVG play triangle */}
<svg width="12" height="14" viewBox="0 0 12 14" fill="none">
<path
d="M1.5 1.5L10.5 7L1.5 12.5V1.5Z"
fill={isNewest ? "#fff" : "rgba(255,255,255,0.4)"}
/>
</svg>
</div>
{/* Title + Artist */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: "Inter, sans-serif",
fontWeight: 600,
fontSize: 16,
color: isNewest ? TEXT : "rgba(255,255,255,0.85)",
letterSpacing: "-0.3px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{track.title}
</div>
<div
style={{
fontFamily: "Inter, sans-serif",
fontWeight: 400,
fontSize: 13,
color: isNewest ? "rgba(168,85,247,0.9)" : MUTED,
marginTop: 3,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{track.artist}
</div>
</div>
{/* Duration */}
<div
style={{
flexShrink: 0,
fontFamily: "Inter, sans-serif",
fontWeight: 500,
fontSize: 14,
color: isNewest ? MUTED : "rgba(255,255,255,0.3)",
letterSpacing: "0.5px",
}}
>
{track.duration}
</div>
</div>
);
};
// ── CTA Button ────────────────────────────────────────────────────────────────
const CTAButton: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
const localFrame = Math.max(0, frame - CTA_START_FRAME);
const scale = spring({
frame: localFrame,
fps,
from: 0,
to: 1,
config: { damping: 12, stiffness: 200 },
});
const opacity = interpolate(localFrame, [0, 10], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Gentle pulsing glow after appearing
const pulseGlow = localFrame > 15
? 0.5 + Math.sin((localFrame - 15) * 0.15) * 0.25
: 0;
if (frame < CTA_START_FRAME) return null;
return (
<div
style={{
display: "flex",
justifyContent: "center",
marginTop: 28,
opacity,
transform: `scale(${scale})`,
}}
>
<div
style={{
background: "linear-gradient(135deg, #a855f7, #ec4899)",
borderRadius: 100,
padding: "16px 52px",
cursor: "pointer",
boxShadow: `0 0 ${20 + pulseGlow * 24}px rgba(168,85,247,${0.55 + pulseGlow * 0.3}), 0 4px 20px rgba(0,0,0,0.4)`,
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 3L17 10L3 17V3Z" fill="#fff" />
</svg>
<span
style={{
fontFamily: "Inter, sans-serif",
fontWeight: 800,
fontSize: 20,
color: "#fff",
letterSpacing: "2px",
textTransform: "uppercase",
}}
>
LISTEN NOW
</span>
</div>
</div>
);
};
// ── Streaming Platforms Row ───────────────────────────────────────────────────
interface Platform {
name: string;
icon: React.ReactNode;
color: string;
}
const PLATFORMS: Platform[] = [
{
name: "Spotify",
color: "#1ed760",
icon: (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<circle cx="11" cy="11" r="11" fill="#1ed760" />
<path d="M7 8.5C9.5 7.5 13 8 15.5 9.5" stroke="#000" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.5 11C9.5 10.2 12.5 10.5 14.5 11.7" stroke="#000" strokeWidth="1.6" strokeLinecap="round" />
<path d="M8 13.5C9.8 12.9 12 13.1 13.8 14" stroke="#000" strokeWidth="1.4" strokeLinecap="round" />
</svg>
),
},
{
name: "Apple Music",
color: "#fc3c44",
icon: (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect width="22" height="22" rx="5" fill="#fc3c44" />
<path d="M14.5 6.5V13C14.5 14.1 13.6 15 12.5 15C11.4 15 10.5 14.1 10.5 13C10.5 11.9 11.4 11 12.5 11C12.9 11 13.2 11.1 13.5 11.3V8.2L8.5 9.5V15C8.5 16.1 7.6 17 6.5 17" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" />
</svg>
),
},
{
name: "YouTube Music",
color: "#ff0000",
icon: (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<circle cx="11" cy="11" r="11" fill="#ff0000" />
<path d="M8.5 7.5L15.5 11L8.5 14.5V7.5Z" fill="#fff" />
</svg>
),
},
{
name: "Tidal",
color: "#06b6d4",
icon: (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect width="22" height="22" rx="5" fill="#06b6d4" />
<path d="M5 9L8.5 12.5L12 9L15.5 12.5L19 9" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 13L8.5 16.5L12 13L15.5 16.5L19 13" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.6" />
</svg>
),
},
];
const PlatformRow: React.FC<{ frame: number }> = ({ frame }) => {
const opacity = interpolate(frame, [30, 50], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const translateY = interpolate(frame, [30, 50], [20, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.quad),
});
return (
<div
style={{
position: "absolute",
bottom: 32,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 32,
opacity,
transform: `translateY(${translateY}px)`,
}}
>
<span
style={{
fontFamily: "Inter, sans-serif",
fontSize: 12,
fontWeight: 500,
color: "rgba(255,255,255,0.25)",
letterSpacing: "1.5px",
textTransform: "uppercase",
}}
>
Available on
</span>
{PLATFORMS.map((p) => (
<div
key={p.name}
style={{
display: "flex",
alignItems: "center",
gap: 7,
opacity: 0.8,
}}
>
{p.icon}
<span
style={{
fontFamily: "Inter, sans-serif",
fontSize: 13,
fontWeight: 600,
color: "rgba(255,255,255,0.55)",
letterSpacing: "0.3px",
}}
>
{p.name}
</span>
</div>
))}
</div>
);
};
// ── Main Composition ──────────────────────────────────────────────────────────
export const PlaylistPromo: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// How many tracks are currently visible
const visibleTrackCount = TRACKS.reduce((count, _, i) => {
const startFrame = FIRST_TRACK_START + i * TRACK_STAGGER;
return frame >= startFrame ? count + 1 : count;
}, 0);
// The newest visible track index (for highlight)
const newestTrackIndex = visibleTrackCount - 1;
// Background color — slow HSL hue shift
const bgColor = bgHueShift(frame);
// Global fade-in
const globalOpacity = interpolate(frame, [0, 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Subtle purple radial glow that breathes with the music
const glowIntensity = 0.06 + Math.sin(frame * 0.04) * 0.025;
return (
<AbsoluteFill style={{ backgroundColor: bgColor, overflow: "hidden", opacity: globalOpacity }}>
{/* Ambient radial glows */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse 70% 50% at 50% 20%, rgba(168,85,247,${glowIntensity * 2}) 0%, transparent 65%)`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse 50% 40% at 80% 75%, rgba(6,182,212,${glowIntensity}) 0%, transparent 60%)`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse 40% 35% at 15% 80%, rgba(236,72,153,${glowIntensity * 0.8}) 0%, transparent 55%)`,
pointerEvents: "none",
}}
/>
{/* Subtle noise grain overlay (CSS pattern simulation) */}
<div
style={{
position: "absolute",
inset: 0,
opacity: 0.025,
background: `repeating-linear-gradient(
45deg,
rgba(255,255,255,0.03) 0px,
rgba(255,255,255,0.03) 1px,
transparent 1px,
transparent 4px
)`,
pointerEvents: "none",
}}
/>
{/* ── Header (top 32%) ── */}
<HeaderBlock frame={frame} fps={fps} width={width} />
{/* ── Horizontal divider ── */}
<div
style={{
position: "absolute",
top: "33%",
left: "8%",
right: "8%",
height: 1,
background: "linear-gradient(90deg, transparent, rgba(168,85,247,0.3) 30%, rgba(6,182,212,0.3) 70%, transparent)",
opacity: interpolate(frame, [35, 50], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
/>
{/* ── Track list (center area) ── */}
<div
style={{
position: "absolute",
top: "34%",
left: "16%",
right: "16%",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{/* Section label */}
<div
style={{
fontFamily: "Inter, sans-serif",
fontWeight: 600,
fontSize: 11,
color: "rgba(255,255,255,0.22)",
letterSpacing: "2px",
textTransform: "uppercase",
marginBottom: 6,
opacity: interpolate(frame, [38, 50], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
>
Track List
</div>
{/* Track cards */}
{TRACKS.map((track, i) => {
const startFrame = FIRST_TRACK_START + i * TRACK_STAGGER;
if (frame < startFrame) return null;
return (
<TrackCard
key={track.num}
track={track}
trackIndex={i}
frame={frame}
fps={fps}
isNewest={i === newestTrackIndex}
/>
);
})}
{/* CTA Button — appears after all tracks are visible */}
<CTAButton frame={frame} fps={fps} />
</div>
{/* ── Streaming platforms (bottom) ── */}
<PlatformRow frame={frame} />
{/* ── Subtle bottom vignette ── */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 100,
background: "linear-gradient(0deg, rgba(0,0,0,0.5), transparent)",
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
// ── Remotion Root & Config ────────────────────────────────────────────────────
export const RemotionRoot: React.FC = () => (
<Composition
id="remotion-playlist-promo"
component={PlaylistPromo}
durationInFrames={210}
fps={30}
width={1920}
height={1080}
/>
);
export const compositionConfig = {
id: "remotion-playlist-promo",
component: PlaylistPromo,
durationInFrames: 210,
fps: 30,
width: 1920,
height: 1080,
};Playlist Promo Video
A 7-second (210-frame, 30 fps) promotional video for a curated music playlist built entirely in Remotion. The composition opens on a near-black background with a slowly shifting HSL hue wash — cycling from deep purple toward cyan and back over the full duration — giving every frame a living, breathing quality without relying on video files or real audio input. Three soft radial glows in purple, cyan, and pink anchor to the corners of the frame and pulse gently in sync with a global sine wave, creating a spatial depth that makes the dark canvas feel warm rather than flat.
The top third of the screen belongs to the header section: a spring-loaded “NOW PLAYING” pill badge scales in first, followed by the playlist title MIDNIGHT VIBES rendered in a bold 72px gradient that flows from white through purple to pink. Below the title a metadata line (“12 tracks · 48 min”) fades in, and directly beneath it sits a 20-bar ambient visualizer whose bar heights are driven by four stacked sine waves at different frequencies and phases — no real audio API is needed. The bars are gradient-coloured from accent purple to accent cyan across the row, with a soft glow on every bar so they read as light sources rather than flat rectangles.
Six track cards slide in from the left one every 25 frames beginning at frame 40. Each card carries a 4-pixel gradient left border (purple → pink), a circular play icon, track number, title, artist name, and runtime. As each new card enters it briefly scales up to 1.02 and receives a brightness boost and purple box-shadow glow, highlighting it as the newest addition before it settles into the list. Once all six tracks are visible, a “LISTEN NOW” button springs in at frame 185 with a spring-physics scale from zero, backed by a purple-to-pink gradient and a breathing glow that pulses rhythmically. A row of four streaming platform logos (Spotify, Apple Music, YouTube Music, Tidal) rendered as inline SVG fades in at the bottom of the frame from the very first seconds, establishing the distribution context throughout.
Simulated audio data — waveform values are generated mathematically. No real audio file is required.