reelcn
GitHubBack to landing

Speaker card

A speaker's avatar and name, with a ring that swells while they talk.

SpeakerCard, defaults
SpeakerCard, defaults thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/speaker-card.json

Files land in src/reelcn/ with the items it builds on. Dependencies: @remotion/media-utils, remotion.

Usage

<SpeakerCard src={staticFile("episode.mp3")} name="Ada Lovelace" role="Founder" />

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
src*stringAudio file; `staticFile("…")` or a URL.
name*string
rolestring
avatarReactNodeAvatar image; pass an `<Img>`. Initials are drawn when omitted.
sizenumberAvatar diameter, in design units. Default 160.
colorstring
windowInSecondsnumber Default 10.
styleCSSProperties
classNamestring

Built from

Use

  • Podcast clips and audiograms with two or more voices
  • Showing who is speaking when there is no camera

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/speaker-card.tsx
/**
 * @title Speaker Card
 * @category audio
 * @description A speaker's avatar and name, with a ring that swells while they talk.
 * @duration sustained
 * @use Podcast clips and audiograms with two or more voices
 * @use Showing who is speaking when there is no camera
 * @tags speaker, podcast, avatar, audiogram
 * @example
 * <SpeakerCard src={staticFile("episode.mp3")} name="Ada Lovelace" role="Founder" />
 */
import { useWindowedAudioData, visualizeAudioWaveform } from "@remotion/media-utils";
import type React from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";
import { alpha, graphemeInitial, type MotionProps, useMotion, useTheme, useViewport } from "./core";

export type SpeakerCardProps = MotionProps & {
  /** Audio file; `staticFile("…")` or a URL. */
  src: string;
  name: string;
  role?: string;
  /** Avatar image; pass an `<Img>`. Initials are drawn when omitted. */
  avatar?: React.ReactNode;
  /** Avatar diameter, in design units. */
  size?: number;
  color?: string;
  windowInSeconds?: number;
  style?: React.CSSProperties;
  className?: string;
};

export function SpeakerCard({
  src,
  name,
  role,
  avatar,
  size = 160,
  color,
  windowInSeconds = 10,
  style,
  className,
  ...motion
}: SpeakerCardProps) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const { u } = useViewport();
  const theme = useTheme();
  const m = useMotion(motion);
  const { audioData, dataOffsetInSeconds } = useWindowedAudioData({ src, frame, fps, windowInSeconds });
  const samples = audioData
    ? visualizeAudioWaveform({
        fps,
        frame,
        audioData,
        numberOfSamples: 8,
        windowInSeconds: 1 / fps,
        channel: 0,
        dataOffsetInSeconds,
      })
    : [0];
  // Loudness right now: the mean of the window, clamped. `visualizeAudioWaveform`'s
  // minus-one-to-one range alternates sign per index as a drawing convention, not a
  // real polarity, so summing magnitudes (not raw values) keeps a silent clip from
  // averaging out to a false zero. Real mixes leave headroom well under full scale,
  // so the mean gets a bigger gain than a peak reading would need.
  const level = Math.min(1, (samples.reduce((total, value) => total + Math.abs(value), 0) / samples.length) * 24);
  const tone = color ?? theme.colors.accent;
  const diameter = u(size);
  const enter = Math.min(m.enter, 1);

  return (
    <div
      className={className}
      style={{
        display: "flex",
        alignItems: "center",
        gap: u(22),
        opacity: enter * (1 - m.exit),
        translate: `0 ${(1 - enter) * u(16)}px`,
        ...style,
      }}
    >
      <div style={{ position: "relative", width: diameter, height: diameter, display: "grid", placeItems: "center" }}>
        <div
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: "50%",
            border: `${u(5)}px solid ${tone}`,
            scale: String(1 + level * 0.16),
            opacity: 0.35 + level * 0.65,
          }}
        />
        <div
          style={{
            width: diameter - u(22),
            height: diameter - u(22),
            borderRadius: "50%",
            overflow: "hidden",
            background: alpha(tone, 0.22),
            color: theme.colors.foreground,
            display: "grid",
            placeItems: "center",
            fontSize: u(size * 0.3),
            fontWeight: 700,
          }}
        >
          {avatar ?? graphemeInitial(name)}
        </div>
      </div>
      <div>
        <div
          style={{
            fontFamily: theme.fonts.heading,
            fontWeight: theme.headingWeight,
            fontSize: u(38),
            color: theme.colors.foreground,
          }}
        >
          {name}
        </div>
        {role ? <div style={{ fontSize: u(26), color: theme.colors.muted, marginTop: u(4) }}>{role}</div> : null}
      </div>
    </div>
  );
}

On this page