Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion
OBLIVION

A story begins in silence.

TSX
cinematic-intro
"use client"

import React, { useEffect, useRef, useState } from "react"
import { motion, useAnimation } from "framer-motion"

export const componentMeta = {
  name: "Cinematic Intro",
  description: "A breathtaking full-screen cinematic intro sequence with letterboxing and grain. Perfect for immersive landing pages.",
  props: {
    title: { type: "string", description: "Main title text", default: '"OBLIVION"' },
    subtitle: { type: "string", description: "Subtitle text", default: '"A story begins in silence."' },
    duration: { type: "number", description: "Total duration in ms", default: "4000" },
    onComplete: { type: "function", description: "Callback when animation finishes" }
  }
}

const BARS_OPEN_DELAY = 0.2
const TITLE_DELAY = 0.7
const SUBTITLE_DELAY = 1.1
const LINE_DELAY = 0.95
const HOLD_DURATION = 1.4
const EXIT_DURATION = 0.9

function LetterboxBars() {
  return (
    <>
      <motion.div
        style={styles.barTop}
        initial={{ scaleY: 1 }}
        animate={{ scaleY: 0 }}
        transition={{ duration: 0.9, delay: BARS_OPEN_DELAY, ease: [0.76, 0, 0.24, 1] }}
        exit={{ scaleY: 1 }}
      />
      <motion.div
        style={styles.barBottom}
        initial={{ scaleY: 1 }}
        animate={{ scaleY: 0 }}
        transition={{ duration: 0.9, delay: BARS_OPEN_DELAY, ease: [0.76, 0, 0.24, 1] }}
        exit={{ scaleY: 1 }}
      />
    </>
  )
}

function GrainOverlay() {
  return (
    <svg style={styles.grain} xmlns="http://www.w3.org/2000/svg">
      <filter id="grain-filter">
        <feTurbulence
          type="fractalNoise"
          baseFrequency="0.65"
          numOctaves="3"
          stitchTiles="stitch"
        />
        <feColorMatrix type="saturate" values="0" />
        <feBlend in="SourceGraphic" mode="overlay" />
      </filter>
      <rect width="100%" height="100%" filter="url(#grain-filter)" opacity="0.06" />
    </svg>
  )
}

function TitleCard({ title, subtitle }: { title: string; subtitle?: string }) {
  return (
    <motion.div
      style={styles.titleWrapper}
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.6, delay: TITLE_DELAY - 0.1 }}
    >
      <motion.div
        style={styles.line}
        initial={{ scaleX: 0, opacity: 0 }}
        animate={{ scaleX: 1, opacity: 1 }}
        transition={{ duration: 0.7, delay: LINE_DELAY, ease: [0.16, 1, 0.3, 1] }}
      />

      <div style={styles.titleRow} aria-label={title}>
        {title.split("").map((char, i) => (
          <motion.span
            key={i}
            style={char === " " ? styles.titleSpace : styles.titleChar}
            initial={{ opacity: 0, y: 18 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{
              duration: 0.55,
              delay: TITLE_DELAY + i * 0.04,
              ease: [0.16, 1, 0.3, 1],
            }}
          >
            {char === " " ? " " : char}
          </motion.span>
        ))}
      </div>

      {subtitle && (
        <motion.p
          style={styles.subtitle}
          initial={{ opacity: 0, y: 8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.6, delay: SUBTITLE_DELAY, ease: "easeOut" }}
        >
          {subtitle}
        </motion.p>
      )}

      <motion.div
        style={{ ...styles.line, marginTop: "1.6rem" }}
        initial={{ scaleX: 0, opacity: 0 }}
        animate={{ scaleX: 1, opacity: 1 }}
        transition={{ duration: 0.7, delay: LINE_DELAY + 0.12, ease: [0.16, 1, 0.3, 1] }}
      />
    </motion.div>
  )
}

function CinematicIntroContent({
  title = "OBLIVION",
  subtitle = "A story begins in silence.",
  duration = 4000,
  onComplete,
}: {
  title?: string
  subtitle?: string
  duration?: number
  onComplete?: () => void
}) {
  const controls = useAnimation()
  const timerRef = useRef<NodeJS.Timeout | null>(null)

  const totalBeforeExit = (SUBTITLE_DELAY + HOLD_DURATION) * 1000

  useEffect(() => {
    // We intentionally start the fadeout near the end of the duration
    timerRef.current = setTimeout(async () => {
      await controls.start("exit")
      onComplete?.()
    }, Math.max(totalBeforeExit, duration - EXIT_DURATION * 1000))

    return () => {
      if (timerRef.current) clearTimeout(timerRef.current)
    }
  }, [controls, duration, onComplete, totalBeforeExit])

  return (
    <motion.div
      style={styles.root}
      variants={{
        exit: { opacity: 0 },
      }}
      animate={controls}
      transition={{ duration: EXIT_DURATION, ease: "easeInOut" }}
    >
      <GrainOverlay />
      <LetterboxBars />
      <TitleCard title={title} subtitle={subtitle} />
      <div style={styles.vignette} />
    </motion.div>
  )
}

export default function CinematicIntro(props: {
  title?: string
  subtitle?: string
  duration?: number
  onComplete?: () => void
}) {
  const [key, setKey] = useState(0)

  return (
    <CinematicIntroContent
      key={key}
      {...props}
      onComplete={() => {
        if (props.onComplete) props.onComplete()
        setTimeout(() => {
          setKey((prev) => prev + 1)
        }, 800)
      }}
    />
  )
}

const styles: Record<string, React.CSSProperties> = {
  root: {
    position: "absolute", // Adjusted from "fixed" to play nicely in component previews
    inset: 0,
    zIndex: 9999,
    backgroundColor: "#080808",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    overflow: "hidden",
    fontFamily: "'Times New Roman', Times, serif",
    borderRadius: "inherit",
  },
  barTop: {
    position: "absolute",
    top: 0,
    left: 0,
    right: 0,
    height: "13%",
    backgroundColor: "#000",
    transformOrigin: "top",
    zIndex: 10,
  },
  barBottom: {
    position: "absolute",
    bottom: 0,
    left: 0,
    right: 0,
    height: "13%",
    backgroundColor: "#000",
    transformOrigin: "bottom",
    zIndex: 10,
  },
  grain: {
    position: "absolute",
    inset: 0,
    width: "100%",
    height: "100%",
    pointerEvents: "none",
    zIndex: 1,
  },
  vignette: {
    position: "absolute",
    inset: 0,
    background: "radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.75) 100%)",
    pointerEvents: "none",
    zIndex: 2,
  },
  titleWrapper: {
    position: "relative",
    zIndex: 5,
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    gap: "0.6rem",
    textAlign: "center",
    padding: "0 2rem",
  },
  line: {
    width: "9rem",
    height: "1px",
    backgroundColor: "rgba(255,255,255,0.25)",
    transformOrigin: "left",
    marginBottom: "0.8rem",
  },
  titleRow: {
    display: "flex",
    flexWrap: "wrap",
    justifyContent: "center",
    overflow: "hidden",
  },
  titleChar: {
    display: "inline-block",
    fontSize: "clamp(2.8rem, 7vw, 6rem)",
    fontWeight: 400,
    letterSpacing: "0.28em",
    color: "#f0ece3",
    lineHeight: 1,
    textTransform: "uppercase",
  },
  titleSpace: {
    display: "inline-block",
    width: "0.4em",
  },
  subtitle: {
    fontSize: "clamp(0.65rem, 1.2vw, 0.8rem)",
    letterSpacing: "0.35em",
    color: "rgba(240,236,227,0.38)",
    textTransform: "uppercase",
    margin: 0,
    fontFamily: "'DM Sans', sans-serif",
  },
}
Save to favoritesCopy to Figma
React

Cinematic Intro

A breathtaking full-screen cinematic intro sequence with letterboxing and grain. Perfect for immersive landing pages.

Installation

$ npx @cosmoo/oblivion add cinematic-intro
Dependenciesnpm install framer-motion

Usage Example

tsx
import CinematicIntro from "@/components/oblivion/cinematic-intro"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <CinematicIntro />
    </div>
  )
}