reelcn
GitHubBack to landing

Lower third

Name and title strap that slides in above the bottom safe zone and centers itself in vertical video.

LowerThird, bar
LowerThird, bar thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/lower-third.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion.

Usage

<Sequence durationInFrames={120}>
  <LowerThird name="Ada Lovelace" title="Founder, Analytical Engines" variant="card" />
</Sequence>

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.
name*string
titlestring
variant"minimal" | "bar" | "card" Default "bar".
align"left" | "right" | "center"Defaults to left, or center in portrait.
colorstringName color. Defaults to the theme foreground.
mutedColorstringTitle color. Defaults to the theme muted color.
accentColorstringBar and rule color. Defaults to the theme accent.
backgroundstringCard fill for the card variant. Defaults to the theme surface.
borderColorstringCard border for the card variant. Defaults to the theme border.
styleCSSProperties
classNamestring

Built from

Use

  • Interviews, podcasts, tutorials and talking-head intros
  • Naming a speaker, product or location on screen

Guides

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

Source

registry/items/lower-third.tsx
/**
 * @title Lower Third
 * @category social
 * @description Name and title strap that slides in above the bottom safe zone and centers itself in vertical video.
 * @duration 30
 * @use Interviews, podcasts, tutorials and talking-head intros
 * @use Naming a speaker, product or location on screen
 * @tags name, title, strap, interview, podcast
 * @example
 * <Sequence durationInFrames={120}>
 *   <LowerThird name="Ada Lovelace" title="Founder, Analytical Engines" variant="card" />
 * </Sequence>
 */
import type React from "react";
import { AbsoluteFill } from "remotion";
import { alpha, type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";

export type LowerThirdProps = MotionProps & {
  name: string;
  title?: string;
  variant?: "bar" | "card" | "minimal";
  /** Defaults to `left`, or `center` in portrait. */
  align?: "left" | "center" | "right";
  /** Name color. Defaults to the theme foreground. */
  color?: string;
  /** Title color. Defaults to the theme muted color. */
  mutedColor?: string;
  /** Bar and rule color. Defaults to the theme accent. */
  accentColor?: string;
  /** Card fill for the `card` variant. Defaults to the theme surface. */
  background?: string;
  /** Card border for the `card` variant. Defaults to the theme border. */
  borderColor?: string;
  style?: React.CSSProperties;
  className?: string;
};

/** Reveal the card from its anchored edge; a centered card opens from the middle. */
function revealClip(progress: number, side: "left" | "center" | "right", radiusPx: number) {
  const hidden = (1 - Math.min(progress, 1)) * 100;
  if (side === "left") return `inset(0 ${hidden}% 0 0 round ${radiusPx}px)`;
  if (side === "right") return `inset(0 0 0 ${hidden}% round ${radiusPx}px)`;
  return `inset(0 ${hidden / 2}% 0 ${hidden / 2}% round ${radiusPx}px)`;
}

export function LowerThird({
  name,
  title,
  variant = "bar",
  align,
  color,
  mutedColor,
  accentColor,
  background,
  borderColor,
  style,
  className,
  ...motion
}: LowerThirdProps) {
  const theme = useTheme();
  const { u, safe, isPortrait } = useViewport();
  const m = useMotion(motion);
  const side = align ?? (isPortrait ? "center" : "left");
  const accent = accentColor ?? theme.colors.accent;
  const at = (offset: number) =>
    tween(m.frame, m.fps, { from: m.delay + m.enterFrames * offset, duration: m.enterFrames, motion: m.preset });
  const lineProgress = at(0);
  const nameProgress = at(0.2);
  const titleProgress = at(0.4);
  const radiusPx = u(theme.radius);
  const showBar = variant === "bar" && side !== "center";
  const showRule = variant === "minimal" || (variant === "bar" && side === "center");

  return (
    <AbsoluteFill
      className={className}
      style={{
        justifyContent: "flex-end",
        alignItems: side === "left" ? "flex-start" : side === "right" ? "flex-end" : "center",
        padding: `0 ${safe.x}px ${safe.bottom}px`,
        fontFamily: theme.fonts.body,
        ...style,
      }}
    >
      <div
        style={{
          display: "flex",
          flexDirection: side === "right" ? "row-reverse" : "row",
          alignItems: "stretch",
          gap: u(20),
          textAlign: side,
          opacity: 1 - m.exit,
          translate: `${(side === "right" ? 1 : -1) * m.exit * u(40)}px 0`,
          ...(variant === "card" && {
            background: background ?? alpha(theme.colors.surface, 0.92),
            border: `1px solid ${borderColor ?? theme.colors.border}`,
            borderRadius: radiusPx,
            padding: `${u(24)}px ${u(34)}px`,
            boxShadow: `0 ${u(18)}px ${u(54)}px ${alpha("#000000", 0.3)}`,
            // The clip only animates the reveal; dropping it afterwards lets the card's shadow show.
            clipPath: lineProgress < 1 ? revealClip(lineProgress, side, radiusPx) : undefined,
          }),
        }}
      >
        {showBar && (
          <div
            style={{
              width: u(8),
              borderRadius: u(4),
              background: accent,
              scale: `1 ${Math.min(lineProgress, 1)}`,
              transformOrigin: "bottom",
            }}
          />
        )}
        <div>
          <div style={{ overflow: "hidden", paddingBottom: "0.1em" }}>
            <div
              style={{
                fontFamily: theme.fonts.heading,
                fontWeight: theme.headingWeight,
                fontSize: u(isPortrait ? 64 : 56),
                lineHeight: 1.1,
                letterSpacing: "-0.02em",
                color: color ?? theme.colors.foreground,
                translate: `0 ${(1 - nameProgress) * 110}%`,
              }}
            >
              {name}
            </div>
          </div>
          {title && (
            <div
              style={{
                fontSize: u(isPortrait ? 34 : 30),
                color: mutedColor ?? theme.colors.muted,
                marginTop: u(6),
                opacity: Math.min(Math.max(titleProgress, 0), 1),
                translate: `0 ${(1 - titleProgress) * u(16)}px`,
              }}
            >
              {title}
            </div>
          )}
          {showRule && (
            <div
              style={{
                height: u(5),
                borderRadius: u(3),
                background: accent,
                marginTop: u(14),
                scale: `${Math.min(lineProgress, 1)} 1`,
                transformOrigin: side === "right" ? "right" : side === "center" ? "center" : "left",
              }}
            />
          )}
        </div>
      </div>
    </AbsoluteFill>
  );
}

On this page