reelcn
GitHubBack to landing

End screen

Closing screen with a next-video slot, a subscribe slot and a countdown.

EndScreen, defaults
EndScreen, defaults thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/end-screen.json

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

Usage

<Sequence from={900} durationInFrames={240}>
  <EndScreen title="Watch this next" nextTitle="How we cut render times in half" seconds={8} />
</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.
titlestring Default "Watch this next".
nextTitle*string
thumbnailReactNodeThumbnail for the next video; pass an `<Img>`. A placeholder frame is drawn when omitted.
channelstring
channelAvatarReactNodeChannel avatar/thumbnail; pass an `<Img>`. A solid accent circle is drawn when omitted.
secondsnumberCountdown shown on the next-video card. Default 8.
subscribeLabelstringHeading over the subscribe circle. Defaults to "Subscribe". Default "Subscribe".
subscribeSubtitlestringLine under the subscribe heading. Defaults to "It is free". Default "It is free".
styleCSSProperties
classNamestring

Built from

Use

  • The last ten seconds of a long-form video
  • Handing viewers one clear next step

Guides

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

Source

registry/items/end-screen.tsx
/**
 * @title End Screen
 * @category social
 * @description Closing screen with a next-video slot, a subscribe slot and a countdown.
 * @duration 60
 * @use The last ten seconds of a long-form video
 * @use Handing viewers one clear next step
 * @tags end screen, outro, youtube, cta
 * @example
 * <Sequence from={900} durationInFrames={240}>
 *   <EndScreen title="Watch this next" nextTitle="How we cut render times in half" seconds={8} />
 * </Sequence>
 */
import type React from "react";
import { AbsoluteFill } from "remotion";
import { alpha, type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";

export type EndScreenProps = MotionProps & {
  title?: string;
  nextTitle: string;
  /** Thumbnail for the next video; pass an `<Img>`. A placeholder frame is drawn when omitted. */
  thumbnail?: React.ReactNode;
  channel?: string;
  /** Channel avatar/thumbnail; pass an `<Img>`. A solid accent circle is drawn when omitted. */
  channelAvatar?: React.ReactNode;
  /** Countdown shown on the next-video card. */
  seconds?: number;
  /** Heading over the subscribe circle. Defaults to "Subscribe". */
  subscribeLabel?: string;
  /** Line under the subscribe heading. Defaults to "It is free". */
  subscribeSubtitle?: string;
  style?: React.CSSProperties;
  className?: string;
};

export function EndScreen({
  title = "Watch this next",
  nextTitle,
  thumbnail,
  channel,
  channelAvatar,
  seconds = 8,
  subscribeLabel = "Subscribe",
  subscribeSubtitle = "It is free",
  style,
  className,
  ...motion
}: EndScreenProps) {
  const theme = useTheme();
  const { u, safe, isPortrait } = useViewport();
  const m = useMotion(motion);
  const card = tween(m.frame, m.fps, { from: m.delay, duration: m.enterFrames, motion: m.preset });
  const side = tween(m.frame, m.fps, {
    from: m.delay + Math.round(m.fps * 0.2),
    duration: m.enterFrames,
    motion: m.preset,
  });
  const left = Math.max(0, seconds - Math.floor((m.frame - m.delay) / m.fps));

  return (
    <AbsoluteFill
      className={className}
      style={{
        alignItems: "center",
        justifyContent: "center",
        gap: u(28),
        padding: `${safe.top}px ${safe.x}px ${safe.bottom}px`,
        flexDirection: isPortrait ? "column" : "row",
        opacity: 1 - m.exit,
        ...style,
      }}
    >
      <div
        style={{
          width: isPortrait ? "100%" : u(620),
          borderRadius: u(theme.radius),
          overflow: "hidden",
          background: theme.colors.surface,
          border: `1px solid ${theme.colors.border}`,
          opacity: Math.min(card, 1),
          translate: `0 ${(1 - Math.min(card, 1)) * u(26)}px`,
        }}
      >
        <div
          style={{
            aspectRatio: "16 / 9",
            background: theme.colors.background,
            position: "relative",
            overflow: "hidden",
          }}
        >
          {thumbnail ?? (
            <div
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                flexDirection: "column",
                padding: u(14),
                gap: u(8),
              }}
            >
              <div
                style={{
                  width: "45%",
                  height: u(14),
                  borderRadius: u(4),
                  background: alpha(theme.colors.foreground, 0.5),
                }}
              />
              <div style={{ flex: 1, borderRadius: u(6), background: alpha(theme.colors.accent, 0.22) }} />
              <div
                style={{
                  width: "70%",
                  height: u(10),
                  borderRadius: u(4),
                  background: alpha(theme.colors.foreground, 0.25),
                }}
              />
            </div>
          )}
          <div
            style={{
              position: "absolute",
              right: u(12),
              bottom: u(12),
              padding: `${u(4)}px ${u(10)}px`,
              borderRadius: u(6),
              background: alpha(theme.colors.background, 0.8),
              color: theme.colors.foreground,
              fontSize: u(22),
              fontVariantNumeric: "tabular-nums",
            }}
          >
            {left}
          </div>
        </div>
        <div style={{ padding: u(20) }}>
          <div style={{ fontSize: u(22), color: theme.colors.accent, marginBottom: u(6) }}>{title}</div>
          <div
            style={{
              fontFamily: theme.fonts.heading,
              fontWeight: theme.headingWeight,
              fontSize: u(34),
              lineHeight: 1.15,
              color: theme.colors.foreground,
            }}
          >
            {nextTitle}
          </div>
          {channel ? (
            <div style={{ fontSize: u(22), color: theme.colors.muted, marginTop: u(8) }}>{channel}</div>
          ) : null}
        </div>
      </div>
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          gap: u(12),
          padding: u(24),
          borderRadius: u(theme.radius),
          background: alpha(theme.colors.surface, 0.8),
          border: `1px solid ${theme.colors.border}`,
          opacity: Math.min(side, 1),
          translate: `0 ${(1 - Math.min(side, 1)) * u(26)}px`,
        }}
      >
        <div
          style={{
            width: u(88),
            height: u(88),
            borderRadius: "50%",
            background: theme.colors.accent,
            overflow: "hidden",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            color: theme.colors.accentForeground,
            fontFamily: theme.fonts.heading,
            fontWeight: theme.headingWeight,
            fontSize: u(34),
          }}
        >
          {channelAvatar ?? channel?.trim()[0]?.toUpperCase() ?? "?"}
        </div>
        <div style={{ fontSize: u(26), fontWeight: 650, color: theme.colors.foreground }}>{subscribeLabel}</div>
        <div style={{ fontSize: u(20), color: theme.colors.muted }}>{subscribeSubtitle}</div>
      </div>
    </AbsoluteFill>
  );
}

On this page