Speaker card
A speaker's avatar and name, with a ring that swells while they talk.
SpeakerCard, defaults
Installation
npx shadcn add https://www.reelcn.dev/r/speaker-card.jsonFiles 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>
);
}