StealThis .dev

Remotion — Now-Playing Card Animation

A sleek, dark frosted-glass Now-Playing card rendered in Remotion — springs in from below, shows rotating vinyl album art, a live-pulsing progress bar, 20-bar audio visualizer reacting to layered sine waves, SVG playback controls, and a spring-bounced heart liked animation — perfect as a social clip overlay or music-app UI showcase.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

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

// ─── Track metadata ───────────────────────────────────────────────────────────
const TRACK_TITLE = "Neon Horizon";
const ARTIST_NAME = "Synthwave Collective";
const ALBUM_NAME = "Electric Dreams, Vol. 3";
const TOTAL_TIME = "3:47";
const CURRENT_TIME_START = "1:22";
const CURRENT_TIME_END = "1:42";

// ─── Design tokens ────────────────────────────────────────────────────────────
const BG = "#0a0a0f";
const SURFACE = "#1e1e2e";
const ACCENT = "#a855f7";
const ACCENT_2 = "#06b6d4";
const ACCENT_3 = "#ec4899";
const TEXT = "#f1f5f9";
const MUTED = "#94a3b8";
const BORDER = "rgba(255,255,255,0.08)";

const BAR_COUNT = 20;

// ─── Helpers ─────────────────────────────────────────────────────────────────
function simAudio(frame: number, barIndex: number): number {
  // Layer three sine waves per bar for an organic feel
  const f = frame;
  const i = barIndex;
  const w1 = Math.sin(f * 0.18 + i * 0.55) * 0.45;
  const w2 = Math.sin(f * 0.09 + i * 1.1 + 1.3) * 0.30;
  const w3 = Math.sin(f * 0.31 + i * 0.28 + 2.7) * 0.15;
  return Math.max(0.08, 0.5 + w1 + w2 + w3);
}

function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

function formatTime(startSec: number, endSec: number, progress: number): string {
  const sec = Math.round(lerp(startSec, endSec, progress));
  const m = Math.floor(sec / 60);
  const s = sec % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

// ─── SVG icons ────────────────────────────────────────────────────────────────
const IconPrev: React.FC<{ color: string }> = ({ color }) => (
  <svg width="28" height="28" viewBox="0 0 24 24" fill="none">
    <path d="M19 20 L9 12 L19 4V20Z" fill={color} />
    <rect x="5" y="4" width="2" height="16" rx="1" fill={color} />
  </svg>
);

const IconNext: React.FC<{ color: string }> = ({ color }) => (
  <svg width="28" height="28" viewBox="0 0 24 24" fill="none">
    <path d="M5 4 L15 12 L5 20V4Z" fill={color} />
    <rect x="17" y="4" width="2" height="16" rx="1" fill={color} />
  </svg>
);

const IconPlay: React.FC<{ color: string; scale: number }> = ({ color, scale }) => (
  <svg
    width="52"
    height="52"
    viewBox="0 0 52 52"
    fill="none"
    style={{ transform: `scale(${scale})` }}
  >
    <circle cx="26" cy="26" r="25" fill={ACCENT} opacity={0.18} />
    <circle cx="26" cy="26" r="22" fill={ACCENT} opacity={0.28} />
    <circle cx="26" cy="26" r="18" fill={ACCENT} />
    <path d="M22 17 L36 26 L22 35V17Z" fill={color} />
  </svg>
);

const IconHeart: React.FC<{ scale: number; opacity: number }> = ({ scale, opacity }) => (
  <svg
    width="32"
    height="32"
    viewBox="0 0 24 24"
    fill={ACCENT_3}
    style={{
      transform: `scale(${scale})`,
      opacity,
      filter: `drop-shadow(0 0 6px ${ACCENT_3})`,
    }}
  >
    <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.27 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.77-3.4 6.86-8.55 11.54L12 21.35z" />
  </svg>
);

// ─── Album Art with vinyl overlay ────────────────────────────────────────────
const AlbumArt: React.FC<{ frame: number; size: number }> = ({ frame, size }) => {
  const rotationDeg = frame * 0.5;
  const vinylRingOpacity = 0.35 + Math.sin(frame * 0.08) * 0.08;

  return (
    <div
      style={{
        width: size,
        height: size,
        borderRadius: 12,
        overflow: "hidden",
        position: "relative",
        flexShrink: 0,
        boxShadow: `0 8px 40px rgba(168,85,247,0.45), 0 2px 12px rgba(0,0,0,0.6)`,
      }}
    >
      {/* Gradient album art base */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background: `linear-gradient(135deg, #a855f7 0%, #ec4899 50%, #06b6d4 100%)`,
        }}
      />

      {/* Inner pattern */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background: `
            radial-gradient(circle at 30% 30%, rgba(255,255,255,0.18) 0%, transparent 55%),
            radial-gradient(circle at 70% 70%, rgba(6,182,212,0.3) 0%, transparent 55%)
          `,
        }}
      />

      {/* Vinyl record overlay (rotating) */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <div
          style={{
            width: size * 0.78,
            height: size * 0.78,
            borderRadius: "50%",
            background: `radial-gradient(circle, #1a1a2e 18%, transparent 18%),
              repeating-radial-gradient(circle, rgba(0,0,0,0.55) 0px, rgba(0,0,0,0.55) 1.5px, transparent 1.5px, transparent 5px)`,
            transform: `rotate(${rotationDeg}deg)`,
            opacity: vinylRingOpacity,
          }}
        />
      </div>

      {/* Center label dot */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <div
          style={{
            width: 18,
            height: 18,
            borderRadius: "50%",
            background: ACCENT,
            boxShadow: `0 0 10px ${ACCENT}`,
            opacity: 0.9,
          }}
        />
      </div>
    </div>
  );
};

// ─── Mini visualizer bars ─────────────────────────────────────────────────────
const Visualizer: React.FC<{ frame: number }> = ({ frame }) => {
  const BAR_W = 5;
  const BAR_GAP = 3;
  const MAX_H = 48;

  return (
    <div
      style={{
        display: "flex",
        alignItems: "flex-end",
        gap: BAR_GAP,
        height: MAX_H,
      }}
    >
      {Array.from({ length: BAR_COUNT }, (_, i) => {
        const amp = simAudio(frame, i);
        const barH = Math.round(amp * MAX_H);
        const hue = 270 + i * 4; // purple → pink sweep
        return (
          <div
            key={i}
            style={{
              width: BAR_W,
              height: barH,
              borderRadius: 3,
              background: `hsl(${hue}, 90%, 65%)`,
              boxShadow: `0 0 6px hsl(${hue}, 90%, 65%)`,
              transition: "height 0.05s linear",
            }}
          />
        );
      })}
    </div>
  );
};

// ─── Progress bar ─────────────────────────────────────────────────────────────
const ProgressBar: React.FC<{
  progress: number;
  currentTime: string;
}> = ({ progress, currentTime }) => (
  <div style={{ width: "100%" }}>
    <div
      style={{
        width: "100%",
        height: 4,
        borderRadius: 2,
        background: "rgba(255,255,255,0.12)",
        overflow: "hidden",
        position: "relative",
      }}
    >
      <div
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          height: "100%",
          width: `${progress * 100}%`,
          background: `linear-gradient(90deg, ${ACCENT}, ${ACCENT_3})`,
          borderRadius: 2,
          boxShadow: `0 0 10px ${ACCENT}`,
        }}
      />
      {/* Scrubber dot */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: `${progress * 100}%`,
          transform: "translate(-50%, -50%)",
          width: 10,
          height: 10,
          borderRadius: "50%",
          background: TEXT,
          boxShadow: `0 0 8px rgba(255,255,255,0.8)`,
        }}
      />
    </div>
    <div
      style={{
        display: "flex",
        justifyContent: "space-between",
        marginTop: 6,
        fontFamily: "Inter, sans-serif",
        fontSize: 12,
        color: MUTED,
        fontVariantNumeric: "tabular-nums",
      }}
    >
      <span>{currentTime}</span>
      <span>{TOTAL_TIME}</span>
    </div>
  </div>
);

// ─── Main component ───────────────────────────────────────────────────────────
export const NowPlayingCard: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps, width, height, durationInFrames } = useVideoConfig();

  // Card slide-up entrance spring
  const cardY = spring({
    frame,
    fps,
    config: { damping: 18, stiffness: 120, mass: 0.9 },
    durationInFrames: 24,
    from: 120,
    to: 0,
  });

  // Card opacity
  const cardOpacity = interpolate(frame, [0, 10], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Progress bar: 15% → 45% over the clip
  const progressRaw = interpolate(
    frame,
    [0, durationInFrames - 1],
    [0.15, 0.45],
    { extrapolateRight: "clamp" }
  );

  // Current time: 1:22 → 1:42 (82s → 102s)
  const currentTimeSec = Math.round(lerp(82, 102, (frame / (durationInFrames - 1))));
  const currentTimeStr = formatTime(82, 102, frame / (durationInFrames - 1));

  // Play button pulse
  const playPulse =
    1 + Math.sin(frame * 0.18) * 0.06;

  // Heart animation at frame 90
  const heartScale = spring({
    frame: frame - 90,
    fps,
    config: { damping: 8, stiffness: 200, mass: 0.6 },
    durationInFrames: 20,
    from: 0,
    to: 1,
  });
  const heartOpacity = interpolate(frame, [90, 95], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  const CARD_W = 700;
  const CARD_H = 210;
  const ART_SIZE = 160;

  return (
    <AbsoluteFill
      style={{
        background: `radial-gradient(ellipse at 30% 60%, rgba(168,85,247,0.12) 0%, transparent 55%),
          radial-gradient(ellipse at 75% 35%, rgba(6,182,212,0.08) 0%, transparent 50%),
          ${BG}`,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        fontFamily: "Inter, sans-serif",
      }}
    >
      {/* Ambient glow behind card */}
      <div
        style={{
          position: "absolute",
          width: CARD_W + 120,
          height: CARD_H + 120,
          borderRadius: 40,
          background: `radial-gradient(ellipse, rgba(168,85,247,0.18) 0%, transparent 70%)`,
          filter: "blur(28px)",
          transform: `translateY(${cardY}px)`,
          opacity: cardOpacity,
        }}
      />

      {/* Card */}
      <div
        style={{
          width: CARD_W,
          height: CARD_H,
          borderRadius: 20,
          background: SURFACE,
          border: `1px solid ${BORDER}`,
          backdropFilter: "blur(16px)",
          boxShadow: `
            0 32px 80px rgba(0,0,0,0.7),
            0 0 0 1px rgba(168,85,247,0.18),
            inset 0 1px 0 rgba(255,255,255,0.06)
          `,
          transform: `translateY(${cardY}px)`,
          opacity: cardOpacity,
          display: "flex",
          flexDirection: "column",
          padding: "22px 26px 18px 22px",
          gap: 14,
          position: "relative",
          overflow: "hidden",
        }}
      >
        {/* Subtle top-left gradient sheen */}
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: 220,
            height: 110,
            background:
              "radial-gradient(ellipse at 0% 0%, rgba(168,85,247,0.12), transparent 70%)",
            pointerEvents: "none",
          }}
        />

        {/* Main row: album art + info */}
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 20,
            flex: 1,
          }}
        >
          {/* Album art */}
          <AlbumArt frame={frame} size={ART_SIZE} />

          {/* Track info + visualizer */}
          <div
            style={{
              flex: 1,
              display: "flex",
              flexDirection: "column",
              gap: 6,
              minWidth: 0,
            }}
          >
            {/* Playing label */}
            <div
              style={{
                display: "flex",
                alignItems: "center",
                gap: 6,
              }}
            >
              <div
                style={{
                  width: 6,
                  height: 6,
                  borderRadius: "50%",
                  background: ACCENT,
                  boxShadow: `0 0 8px ${ACCENT}`,
                  animation: "none",
                  opacity: 0.5 + 0.5 * Math.sin(frame * 0.22),
                }}
              />
              <span
                style={{
                  fontSize: 11,
                  fontWeight: 600,
                  color: ACCENT,
                  letterSpacing: "0.12em",
                  textTransform: "uppercase",
                }}
              >
                Now Playing
              </span>
            </div>

            {/* Track title */}
            <div
              style={{
                fontSize: 22,
                fontWeight: 800,
                color: TEXT,
                lineHeight: 1.15,
                letterSpacing: "-0.01em",
                whiteSpace: "nowrap",
                overflow: "hidden",
                textOverflow: "ellipsis",
              }}
            >
              {TRACK_TITLE}
            </div>

            {/* Artist */}
            <div
              style={{
                fontSize: 14,
                fontWeight: 500,
                color: ACCENT_2,
                letterSpacing: "0.01em",
              }}
            >
              {ARTIST_NAME}
            </div>

            {/* Album */}
            <div
              style={{
                fontSize: 12,
                color: MUTED,
                letterSpacing: "0.01em",
              }}
            >
              {ALBUM_NAME}
            </div>

            {/* Visualizer */}
            <div style={{ marginTop: 6 }}>
              <Visualizer frame={frame} />
            </div>
          </div>

          {/* Heart */}
          <div
            style={{
              display: "flex",
              alignItems: "flex-start",
              paddingTop: 4,
            }}
          >
            <IconHeart scale={heartScale} opacity={heartOpacity} />
          </div>
        </div>

        {/* Progress bar row */}
        <ProgressBar progress={progressRaw} currentTime={currentTimeStr} />

        {/* Controls */}
        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            gap: 28,
          }}
        >
          <button
            style={{
              background: "none",
              border: "none",
              cursor: "pointer",
              opacity: 0.7,
              display: "flex",
              alignItems: "center",
            }}
          >
            <IconPrev color={MUTED} />
          </button>

          <div style={{ display: "flex", alignItems: "center" }}>
            <IconPlay color={TEXT} scale={playPulse} />
          </div>

          <button
            style={{
              background: "none",
              border: "none",
              cursor: "pointer",
              opacity: 0.7,
              display: "flex",
              alignItems: "center",
            }}
          >
            <IconNext color={MUTED} />
          </button>
        </div>
      </div>

      {/* Floating particles */}
      {[0, 1, 2, 3, 4].map((i) => {
        const px =
          width * 0.2 + i * (width * 0.15) + Math.sin(frame * 0.04 + i * 1.3) * 30;
        const py =
          height * 0.15 +
          Math.sin(frame * 0.06 + i * 0.9) * 60 +
          i * (height * 0.12);
        const pop = interpolate(frame, [i * 10, i * 10 + 18], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });
        const colors = [ACCENT, ACCENT_2, ACCENT_3, "#f59e0b", "#10b981"];
        return (
          <div
            key={i}
            style={{
              position: "absolute",
              left: px,
              top: py,
              width: 5 + i * 1.5,
              height: 5 + i * 1.5,
              borderRadius: "50%",
              background: colors[i % colors.length],
              opacity: pop * (0.3 + Math.sin(frame * 0.07 + i) * 0.15),
              boxShadow: `0 0 12px ${colors[i % colors.length]}`,
              filter: "blur(0.5px)",
            }}
          />
        );
      })}
    </AbsoluteFill>
  );
};

// ─── Composition config ───────────────────────────────────────────────────────
export const compositionConfig = {
  id: "remotion-now-playing",
  component: NowPlayingCard,
  durationInFrames: 120,
  fps: 30,
  width: 1920,
  height: 1080,
};

Now-Playing Card Animation

A 4-second Remotion animation that recreates the familiar “Now Playing” notification card with a premium dark aesthetic. The card — styled with a deep #1e1e2e surface, a soft purple glow border, and a subtle gradient sheen — springs up from below the frame in the first 20 frames using a damped spring, then holds steady while all its content comes alive. Track title, artist, and album are displayed in a clean typographic hierarchy using Inter at multiple weights, layered on top of 160 × 160 px gradient album art whose vinyl record overlay rotates one full degree every two frames.

The audio visualizer sits directly beside the track metadata as a row of 20 narrow bars, each driven by three stacked sine waves at different frequencies and phases — giving each bar an independent, organic pulse that convincingly mimics real spectrum activity without touching any audio API. A thin progress bar beneath the card body fills from 15 % to 45 % over the clip’s duration, complete with a glowing scrubber dot and live elapsed-time counter. At frame 90, a pink heart icon scales in with a high-stiffness spring bounce — an unmistakable “liked” moment — while the pulsing play button subtly scales with a slow sine wave throughout the clip.

Floating ambient particles drift across the dark background, each with its own phase offset and a pop-in interpolation, reinforcing the music-visualizer atmosphere without cluttering the composition. All colors follow the Stealthis dark palette (purple #a855f7, cyan #06b6d4, pink #ec4899) with glow box-shadows and radial gradient halos to ensure every frame looks polished at 1920 × 1080.

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