StealThis .dev

Remotion — Spotify-Style Canvas Loop

A seamlessly looping 3-second Spotify Canvas visual built with Remotion: four large soft-glow gradient blobs (purple, pink, cyan, indigo) drift and pulse across a near-black background, layered waveform SVG paths morph with organic sine motion, a subtle film-grain texture and radial vignette add depth, and a compact album-art corner with animated playback bars anchors the composition — all calculated with frame % 90 for perfect loop continuity.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

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

// ─── Constants ──────────────────────────────────────────────────────────────

const LOOP_FRAMES = 90; // 3 seconds at 30fps

const TRACK_TITLE = "Midnight Drift";
const ARTIST_NAME = "Neon Echo";

// Blob definitions: [colorStop1, colorStop2, baseX, baseY, radius, driftAmpX, driftAmpY, freqX, freqY, phaseX, phaseY, pulseAmp, pulseFreq, pulsePhase]
const BLOBS = [
  {
    c1: "#a855f7",
    c2: "#7c3aed",
    baseX: 0.35,
    baseY: 0.38,
    radius: 480,
    driftAmpX: 60,
    driftAmpY: 45,
    freqX: 0.031,
    freqY: 0.023,
    phaseX: 0,
    phaseY: 1.1,
    pulseAmp: 40,
    pulseFreq: 0.027,
    pulsePhase: 0,
    opacity: 0.52,
  },
  {
    c1: "#ec4899",
    c2: "#be185d",
    baseX: 0.68,
    baseY: 0.55,
    radius: 420,
    driftAmpX: 50,
    driftAmpY: 55,
    freqX: 0.021,
    freqY: 0.035,
    phaseX: 2.1,
    phaseY: 0.7,
    pulseAmp: 35,
    pulseFreq: 0.041,
    pulsePhase: 1.2,
    opacity: 0.45,
  },
  {
    c1: "#06b6d4",
    c2: "#0284c7",
    baseX: 0.52,
    baseY: 0.65,
    radius: 380,
    driftAmpX: 70,
    driftAmpY: 40,
    freqX: 0.039,
    freqY: 0.019,
    phaseX: 3.9,
    phaseY: 2.3,
    pulseAmp: 30,
    pulseFreq: 0.033,
    pulsePhase: 2.4,
    opacity: 0.38,
  },
  {
    c1: "#6366f1",
    c2: "#4338ca",
    baseX: 0.22,
    baseY: 0.62,
    radius: 340,
    driftAmpX: 55,
    driftAmpY: 60,
    freqX: 0.017,
    freqY: 0.043,
    phaseX: 5.5,
    phaseY: 4.1,
    pulseAmp: 25,
    pulseFreq: 0.037,
    pulsePhase: 3.8,
    opacity: 0.42,
  },
];

// Waveform line configs
const WAVE_LINES = [
  { yBase: 0.48, amp1: 28, freq1: 0.11, phase1: 0, amp2: 14, freq2: 0.23, phase2: 1.1, color: "#a855f7", glow: "rgba(168,85,247,0.55)", strokeWidth: 2.5, opacity: 0.85 },
  { yBase: 0.52, amp1: 20, freq1: 0.09, phase1: 2.2, amp2: 10, freq2: 0.19, phase2: 2.8, color: "#ec4899", glow: "rgba(236,72,153,0.45)", strokeWidth: 2, opacity: 0.72 },
  { yBase: 0.50, amp1: 36, freq1: 0.07, phase1: 4.4, amp2: 18, freq2: 0.31, phase2: 0.5, color: "#06b6d4", glow: "rgba(6,182,212,0.40)", strokeWidth: 1.5, opacity: 0.58 },
  { yBase: 0.495, amp1: 12, freq1: 0.15, phase1: 1.7, amp2: 8, freq2: 0.27, phase2: 3.9, color: "#f1f5f9", glow: "rgba(241,245,249,0.25)", strokeWidth: 1.2, opacity: 0.35 },
];

// ─── Utilities ───────────────────────────────────────────────────────────────

/** Seamless oscillation — uses loopFrame (0..LOOP_FRAMES-1) so frame 89→0 is seamless */
function osc(loopFrame: number, freq: number, phase: number): number {
  return Math.sin(loopFrame * freq * (Math.PI * 2) + phase);
}

/** Build an SVG polyline path string for a waveform row */
function buildWavePath(
  loopFrame: number,
  width: number,
  height: number,
  cfg: (typeof WAVE_LINES)[0]
): string {
  const steps = 200;
  const t = loopFrame / LOOP_FRAMES; // 0..1, seamless
  const points: string[] = [];

  for (let i = 0; i <= steps; i++) {
    const x = (i / steps) * width;
    const xNorm = i / steps; // 0..1 along width

    // Two sine components layered
    const y1 = cfg.amp1 * Math.sin(xNorm * Math.PI * 2 * 3 + t * Math.PI * 2 * cfg.freq1 * LOOP_FRAMES * 0.01 + cfg.phase1);
    const y2 = cfg.amp2 * Math.sin(xNorm * Math.PI * 2 * 7 + t * Math.PI * 2 * cfg.freq2 * LOOP_FRAMES * 0.01 + cfg.phase2);
    const y = cfg.yBase * height + y1 + y2;

    points.push(`${x.toFixed(1)},${y.toFixed(1)}`);
  }

  return `M${points.join(" L")}`;
}

// ─── Sub-components ──────────────────────────────────────────────────────────

const GlowBlob: React.FC<{
  blob: (typeof BLOBS)[0];
  loopFrame: number;
  width: number;
  height: number;
}> = ({ blob, loopFrame, width, height }) => {
  const cx = blob.baseX * width + blob.driftAmpX * osc(loopFrame, blob.freqX, blob.phaseX);
  const cy = blob.baseY * height + blob.driftAmpY * osc(loopFrame, blob.freqY, blob.phaseY);
  const r = blob.radius + blob.pulseAmp * osc(loopFrame, blob.pulseFreq, blob.pulsePhase);

  // Unique gradient ID per blob
  const gradId = `blob-grad-${blob.c1.replace("#", "")}`;

  return (
    <g>
      <defs>
        <radialGradient id={gradId} cx="50%" cy="50%" r="50%">
          <stop offset="0%" stopColor={blob.c1} stopOpacity={blob.opacity} />
          <stop offset="60%" stopColor={blob.c2} stopOpacity={blob.opacity * 0.55} />
          <stop offset="100%" stopColor={blob.c2} stopOpacity={0} />
        </radialGradient>
      </defs>
      <ellipse
        cx={cx}
        cy={cy}
        rx={r}
        ry={r * 0.82}
        fill={`url(#${gradId})`}
      />
    </g>
  );
};

const WaveformLines: React.FC<{
  loopFrame: number;
  width: number;
  height: number;
}> = ({ loopFrame, width, height }) => {
  return (
    <svg
      width={width}
      height={height}
      style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
    >
      <defs>
        {WAVE_LINES.map((wl, i) => (
          <filter key={i} id={`wave-glow-${i}`} x="-20%" y="-200%" width="140%" height="500%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur" />
            <feMerge>
              <feMergeNode in="blur" />
              <feMergeNode in="blur" />
              <feMergeNode in="SourceGraphic" />
            </feMerge>
          </filter>
        ))}
      </defs>

      {WAVE_LINES.map((wl, i) => {
        const d = buildWavePath(loopFrame, width, height, wl);
        return (
          <path
            key={i}
            d={d}
            fill="none"
            stroke={wl.color}
            strokeWidth={wl.strokeWidth}
            opacity={wl.opacity}
            filter={`url(#wave-glow-${i})`}
          />
        );
      })}
    </svg>
  );
};

const AlbumArtCorner: React.FC<{
  loopFrame: number;
}> = ({ loopFrame }) => {
  // Subtle pulse on album art
  const artScale = 1 + 0.012 * Math.sin(loopFrame * 0.07 * Math.PI * 2);

  return (
    <div
      style={{
        position: "absolute",
        bottom: 64,
        left: 72,
        display: "flex",
        alignItems: "center",
        gap: 18,
      }}
    >
      {/* Album art */}
      <div
        style={{
          width: 100,
          height: 100,
          borderRadius: 10,
          background: "linear-gradient(135deg, #a855f7 0%, #ec4899 50%, #06b6d4 100%)",
          boxShadow: "0 0 28px rgba(168,85,247,0.55), 0 0 60px rgba(236,72,153,0.25)",
          transform: `scale(${artScale})`,
          flexShrink: 0,
          position: "relative",
          overflow: "hidden",
        }}
      >
        {/* Vinyl grooves overlay */}
        <div
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: 10,
            background:
              "repeating-conic-gradient(rgba(255,255,255,0.04) 0deg, transparent 1deg, transparent 5deg, rgba(255,255,255,0.02) 6deg)",
          }}
        />
        {/* Center dot */}
        <div
          style={{
            position: "absolute",
            top: "50%",
            left: "50%",
            width: 18,
            height: 18,
            borderRadius: "50%",
            background: "rgba(0,0,0,0.6)",
            transform: "translate(-50%, -50%)",
            boxShadow: "inset 0 0 4px rgba(255,255,255,0.2)",
          }}
        />
      </div>

      {/* Track info */}
      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
        <span
          style={{
            fontFamily: "Inter, sans-serif",
            fontWeight: 700,
            fontSize: 18,
            color: "#f1f5f9",
            letterSpacing: -0.3,
            textShadow: "0 2px 12px rgba(0,0,0,0.8)",
          }}
        >
          {TRACK_TITLE}
        </span>
        <span
          style={{
            fontFamily: "Inter, sans-serif",
            fontWeight: 400,
            fontSize: 14,
            color: "rgba(148,163,184,0.9)",
            textShadow: "0 2px 8px rgba(0,0,0,0.8)",
          }}
        >
          {ARTIST_NAME}
        </span>

        {/* Mini playback dots */}
        <div style={{ display: "flex", gap: 3, marginTop: 2 }}>
          {Array.from({ length: 16 }).map((_, i) => {
            const barH = 4 + 8 * Math.abs(Math.sin(loopFrame * 0.13 * Math.PI * 2 + i * 0.55));
            return (
              <div
                key={i}
                style={{
                  width: 3,
                  height: barH,
                  borderRadius: 2,
                  background: "linear-gradient(180deg, #a855f7, #ec4899)",
                  opacity: 0.7 + 0.3 * Math.abs(Math.sin(loopFrame * 0.09 + i)),
                  alignSelf: "flex-end",
                }}
              />
            );
          })}
        </div>
      </div>
    </div>
  );
};

// ─── Main Component ───────────────────────────────────────────────────────────

export const SpotifyCanvas: React.FC = () => {
  const frame = useCurrentFrame();
  const { width, height } = useVideoConfig();

  // Seamless loop frame
  const loopFrame = frame % LOOP_FRAMES;

  // Vignette pulsing ever so slightly
  const vignetteOpacity = 0.72 + 0.04 * Math.sin(loopFrame * 0.043 * Math.PI * 2);

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0a0a0f",
        overflow: "hidden",
      }}
    >
      {/* ── Blob background (SVG layer) ────────────────────────────────────── */}
      <svg
        width={width}
        height={height}
        style={{ position: "absolute", inset: 0 }}
      >
        {/* Global blur for the blob layer */}
        <defs>
          <filter id="blob-blur" x="-30%" y="-30%" width="160%" height="160%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="48" />
          </filter>
        </defs>
        <g filter="url(#blob-blur)">
          {BLOBS.map((blob, i) => (
            <GlowBlob key={i} blob={blob} loopFrame={loopFrame} width={width} height={height} />
          ))}
        </g>
      </svg>

      {/* ── Background noise/grain texture (subtle) ────────────────────────── */}
      <svg
        width={width}
        height={height}
        style={{ position: "absolute", inset: 0, opacity: 0.03, mixBlendMode: "screen" }}
      >
        <defs>
          <filter id="grain">
            <feTurbulence
              type="fractalNoise"
              baseFrequency="0.75"
              numOctaves="4"
              seed={loopFrame * 3}
              stitchTiles="stitch"
              result="noise"
            />
            <feColorMatrix type="saturate" values="0" in="noise" />
          </filter>
        </defs>
        <rect width={width} height={height} filter="url(#grain)" />
      </svg>

      {/* ── Vignette overlay ───────────────────────────────────────────────── */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background:
            "radial-gradient(ellipse at 50% 50%, transparent 30%, rgba(0,0,0,0.7) 100%)",
          opacity: vignetteOpacity,
        }}
      />

      {/* ── Waveform lines ─────────────────────────────────────────────────── */}
      <WaveformLines loopFrame={loopFrame} width={width} height={height} />

      {/* ── Center glow dot ────────────────────────────────────────────────── */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          width: 6,
          height: 6,
          borderRadius: "50%",
          background: "#ffffff",
          transform: "translate(-50%, -50%)",
          boxShadow: `0 0 ${20 + 10 * Math.abs(Math.sin(loopFrame * 0.06 * Math.PI * 2))}px rgba(255,255,255,0.8), 0 0 60px rgba(168,85,247,0.5)`,
        }}
      />

      {/* ── Album art & track info (bottom-left) ──────────────────────────── */}
      <AlbumArtCorner loopFrame={loopFrame} />

      {/* ── Subtle top gradient fade for vertical crop ─────────────────────── */}
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          right: 0,
          height: 180,
          background: "linear-gradient(180deg, rgba(10,10,15,0.4) 0%, transparent 100%)",
          pointerEvents: "none",
        }}
      />
      <div
        style={{
          position: "absolute",
          bottom: 0,
          left: 0,
          right: 0,
          height: 180,
          background: "linear-gradient(0deg, rgba(10,10,15,0.6) 0%, transparent 100%)",
          pointerEvents: "none",
        }}
      />
    </AbsoluteFill>
  );
};

// ─── Remotion Root (Composition wrapper) ─────────────────────────────────────

export const RemotionRoot: React.FC = () => (
  <Composition
    id="remotion-spotify-canvas"
    component={SpotifyCanvas}
    durationInFrames={90}
    fps={30}
    width={1920}
    height={1080}
  />
);

// ─── compositionConfig (for catalog / programmatic use) ──────────────────────

export const compositionConfig = {
  id: "remotion-spotify-canvas",
  component: SpotifyCanvas,
  durationInFrames: 90,
  fps: 30,
  width: 1920,
  height: 1080,
};

Spotify-Style Canvas Loop

This composition recreates the ambient aesthetic of Spotify’s short-form Canvas videos — the looping, full-bleed visuals that play behind a track in the Spotify mobile app. Four large radial-gradient blobs in purple, hot pink, cyan, and indigo drift slowly across a #0a0a0f near-black canvas using independent sine oscillators for X, Y, and radius pulse, each with unique frequencies and phase offsets. All position math uses loopFrame = frame % 90 so the motion state at frame 89 flows seamlessly back into frame 0, making the loop invisible to the eye.

On top of the blob field, four SVG polyline paths trace gently morphing waveforms across the vertical center of the canvas. Each path layers two sine waves at different spatial frequencies and animation speeds, and is rendered with a feGaussianBlur glow filter to give the lines a soft, luminous quality. A procedural feTurbulence grain overlay adds analog warmth, and a radial vignette focuses the viewer’s gaze inward. A centered white dot with a purple-glow box-shadow marks the composition’s energy focal point.

In the bottom-left corner — positioned within the safe center strip for 9:16 crops — a 100 × 100 px album art placeholder uses a purple-to-pink-to-cyan gradient with a vinyl-groove conic pattern overlay and a glowing box-shadow. Beside it, the track title and artist name float in clean Inter text, above a row of sixteen micro equalizer bars that animate with layered sine functions to simulate live audio playback energy. The entire composition requires no external assets or audio files.

Simulated audio data — waveform values are generated mathematically. No real audio file is required.