Catalog
No preview yet

Source

158 lines
// Hero Title Block — big centered headline with a subtitle over an
// optional background video. Spring pop-in, slow zoom, fade-out.
// Good for the first 1–3 seconds of a clip to set context before the
// main content starts.

import {
  AbsoluteFill,
  OffthreadVideo,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
import { loadFont as loadRoboto } from '@remotion/google-fonts/Roboto';
import { loadFont } from '@remotion/google-fonts/SpaceGrotesk';

const { fontFamily: SPACE_GROTESK } = loadFont();
const { fontFamily: INTER } = loadInter();
const { fontFamily: ROBOTO } = loadRoboto();

const FONT_MAP: Record<string, string> = {
  SpaceGrotesk: SPACE_GROTESK,
  Inter: INTER,
  Roboto: ROBOTO,
};

export type HeroTitleProps = {
  backgroundVideo: string;
  title: string;
  subtitle: string;
  titleColor: string;
  titleFontSize: number;
  subtitleColor: string;
  subtitleFontSize: number;
  titleFont: 'SpaceGrotesk' | 'Inter' | 'Roboto';
  verticalAlign: number;
  backgroundColor: string;
  overlayOpacity: number;
};

export default function HeroTitle({
  backgroundVideo,
  title = 'Breaking',
  subtitle = '',
  titleColor = '#FFFFFF',
  titleFontSize = 140,
  subtitleColor = '#FFD84D',
  subtitleFontSize = 40,
  titleFont = 'SpaceGrotesk',
  verticalAlign = 50,
  backgroundColor = '#05060a',
  overlayOpacity = 0.45,
}: Partial<HeroTitleProps>) {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();
  const titleFamily = FONT_MAP[titleFont] ?? SPACE_GROTESK;
  const hasVideo =
    typeof backgroundVideo === 'string' &&
    /^https?:\/\//i.test(backgroundVideo);

  const popIn = spring({
    frame,
    fps,
    config: { damping: 14, stiffness: 180, mass: 0.5 },
  });
  const slowZoom = interpolate(frame, [0, durationInFrames], [1.0, 1.08], {
    extrapolateRight: 'clamp',
  });
  const fadeOut = interpolate(
    frame,
    [Math.max(0, durationInFrames - 15), durationInFrames],
    [1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  return (
    <AbsoluteFill style={{ backgroundColor, overflow: 'hidden' }}>
      {hasVideo ? (
        <OffthreadVideo
          src={backgroundVideo as string}
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            transform: `scale(${slowZoom})`,
            transformOrigin: 'center center',
          }}
          muted
        />
      ) : null}

      {hasVideo && overlayOpacity > 0 ? (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backgroundColor: `rgba(0,0,0,${overlayOpacity})`,
          }}
        />
      ) : null}

      <div
        style={{
          position: 'absolute',
          // Anchor content to verticalAlign% of the viewport height
          // (so user-facing control still maps "0 = top, 50 = center,
          // 100 = bottom"). Using top + translateY(-50%) avoids the
          // paddingTop-% trap where the % resolves to container width.
          top: `${verticalAlign}%`,
          left: 0,
          right: 0,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          transform: `translateY(-50%) scale(${popIn})`,
          opacity: fadeOut,
          pointerEvents: 'none',
        }}
      >
        <div
          style={{
            color: titleColor,
            fontFamily: titleFamily,
            fontSize: titleFontSize,
            fontWeight: 800,
            textAlign: 'center',
            lineHeight: 1.0,
            letterSpacing: '-0.03em',
            textTransform: 'uppercase',
            padding: '0 48px',
            textShadow: '0 4px 24px rgba(0,0,0,0.6)',
          }}
        >
          {title}
        </div>
        {subtitle ? (
          <div
            style={{
              marginTop: 20,
              color: subtitleColor,
              fontFamily: titleFamily,
              fontSize: subtitleFontSize,
              fontWeight: 600,
              textAlign: 'center',
              letterSpacing: '0.12em',
              textTransform: 'uppercase',
              textShadow: '0 2px 8px rgba(0,0,0,0.8)',
            }}
          >
            {subtitle}
          </div>
        ) : null}
      </div>
    </AbsoluteFill>
  );
}

Schema (Zod)

import { z } from 'zod';

export const Schema = z.object({
  backgroundVideo: z
    .string()
    .describe('$port:video Optional background video (muted)'),

  title: z.string().default('Breaking').describe('Title text'),
  subtitle: z.string().default('').describe('Subtitle text (optional)'),

  titleColor: z
    .string()
    .default('#FFFFFF')
    .describe('$style:color Title color'),
  titleFontSize: z
    .number()
    .default(140)
    .describe('$style:size Title font size'),
  subtitleColor: z
    .string()
    .default('#FFD84D')
    .describe('$style:color Subtitle color'),
  subtitleFontSize: z
    .number()
    .default(40)
    .describe('$style:size Subtitle font size'),
  titleFont: z
    .enum(['SpaceGrotesk', 'Inter', 'Roboto'])
    .default('SpaceGrotesk')
    .describe('$style:font Title font'),

  verticalAlign: z
    .number()
    .default(50)
    .describe('$style:position Vertical position (% from top)'),
  backgroundColor: z
    .string()
    .default('#05060a')
    .describe('$style:color Background color (when no video)'),
  overlayOpacity: z
    .number()
    .default(0.45)
    .describe('$style:scale Video darken overlay opacity (0–1)'),
});

Hero Title

hero_title

Big centered title with a subtitle, optional background video. Spring pop-in, slow Ken-Burns zoom, fade-out at the end.

Workflow Inputs (1)

  • Optional background video (muted)
    backgroundVideo
    VIDEORequired

Config Fields (10)

  • title
  • subtitle
  • titleFont
  • titleColor
  • subtitleColor
  • titleFontSize
  • verticalAlign
  • overlayOpacity
  • backgroundColor
  • subtitleFontSize

Meta

Updated
7/10/2026