shadimage
Remocn
componentrelease-teasersource: ok · refreshed

Release Teaser

A 16-second cinematic release teaser with center-out typography, a procedural dimensional mark, moving edge light, and an independent fictional identity.

npx shadcn add "https://www.remocn.dev/r/release-teaser.json"
free

Where it goes

changelog

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
remotion@fontsource/inter

Files8 · source cached 10/7/2026, 7:37:08 PM

registry/remocn-templates/release-teaser/index.tsx
"use client";

import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import { useMemo } from "react";
import {
  AbsoluteFill,
  Audio,
  Sequence,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { TeaserBackground } from "./background";
import { type ReleaseTeaserProps, resolveReleaseTeaserProps } from "./content";
import {
  atFps,
  RELEASE_TEASER_FPS,
  RELEASE_TEASER_FRAMES,
  RELEASE_TEASER_HEIGHT,
  RELEASE_TEASER_WIDTH,
} from "./motion";
import { ReleaseLockup } from "./scenes/closing";
import { Statement } from "./scenes/statement";

export type { ReleaseTeaserProps, ReleaseTeaserTheme } from "./content";
export { releaseTeaserStatements, releaseTeaserTheme } from "./content";
export {
  RELEASE_TEASER_FPS,
  RELEASE_TEASER_FRAMES,
  RELEASE_TEASER_HEIGHT,
  RELEASE_TEASER_WIDTH,
  releaseTeaserTimeline,
} from "./motion";

export function ReleaseTeaser(props: ReleaseTeaserProps) {
  const currentFrame = useCurrentFrame();
  const { fps, width, height } = useVideoConfig();
  const frame = (currentFrame / fps) * RELEASE_TEASER_FPS;
  const {
    brandName,
    release,
    tagline,
    statements,
    accentColor,
    theme,
    logoSrc,
    lightIntensity,
    reducedMotion,
  } = props;
  const scene = useMemo(
    () =>
      resolveReleaseTeaserProps({
        brandName,
        release,
        tagline,
        statements,
        accentColor,
        theme,
        logoSrc,
        lightIntensity,
        reducedMotion,
      }),
    [
      brandName,
      release,
      tagline,
      statements,
      accentColor,
      theme,
      logoSrc,
      lightIntensity,
      reducedMotion,
    ],
  );
  const scale = Math.min(width / 960, height / 540);
  return (
    <AbsoluteFill
      style={{
        background: scene.theme.background,
        overflow: "hidden",
        fontFamily: '"Inter", sans-serif',
        WebkitFontSmoothing: "antialiased",
      }}
    >
      <div
        style={{
          position: "absolute",
          left: (width - 960 * scale) / 2,
          top: (height - 540 * scale) / 2,
          width: 960,
          height: 540,
          scale,
          transformOrigin: "0 0",
          overflow: "hidden",
        }}
      >
        <TeaserBackground scene={scene} frame={frame} />
        <Sequence
          name="An idea"
          from={0}
          durationInFrames={atFps(104, fps)}
          layout="none"
        >
          <Statement
            scene={scene}
            text={scene.statements[0]}
            frame={frame}
            duration={104}
          />
        </Sequence>
        <Sequence
          name="Better work"
          from={atFps(104, fps)}
          durationInFrames={atFps(208, fps) - atFps(104, fps)}
          layout="none"
        >
          <Statement
            scene={scene}
            text={scene.statements[1]}
            frame={frame - 104}
            duration={104}
          />
        </Sequence>
        <Sequence
          name="Every detail"
          from={atFps(208, fps)}
          durationInFrames={atFps(326, fps) - atFps(208, fps)}
          layout="none"
        >
          <Statement
            scene={scene}
            text={scene.statements[2]}
            frame={frame - 208}
            duration={118}
          />
        </Sequence>
        <Sequence
          name="Together"
          from={atFps(326, fps)}
          durationInFrames={atFps(446, fps) - atFps(326, fps)}
          layout="none"
        >
          <Statement
            scene={scene}
            text={scene.statements[3]}
            frame={frame - 326}
            duration={120}
          />
        </Sequence>
        <Sequence
          name="A fresh chapter"
          from={atFps(446, fps)}
          durationInFrames={atFps(610, fps) - atFps(446, fps)}
          layout="none"
        >
          <Statement
            scene={scene}
            text={scene.statements[4]}
            frame={frame - 446}
            duration={164}
          />
        </Sequence>
        <Sequence
          name="Release reveal"
          from={atFps(610, fps)}
          durationInFrames={atFps(960, fps) - atFps(610, fps)}
          layout="none"
        >
          <ReleaseLockup scene={scene} frame={frame - 610} />
        </Sequence>
      </div>
      {props.audioSrc?.trim() ? (
        <Audio
          src={props.audioSrc.trim()}
          volume={
            Number.isFinite(props.volume)
              ? Math.max(0, Math.min(1, props.volume ?? 1))
              : 1
          }
        />
      ) : null}
    </AbsoluteFill>
  );
}

export const releaseTeaserConfig = {
  componentName: "ReleaseTeaser",
  importPath: "@/components/remocn/templates/release-teaser",
  controls: {
    brandName: {
      type: "text-content" as const,
      default: "Orvio",
      description: "Name in the final release lockup",
    },
    release: {
      type: "text-content" as const,
      default: "2",
      description: "Version suffix; leave empty to hide",
    },
    tagline: {
      type: "text-content" as const,
      default: "Your next chapter starts here.",
      description: "Final supporting line",
    },
    accentColor: {
      type: "color" as const,
      default: "#9BC8DD",
      description: "Mark and dimensional edge lighting",
    },
    reducedMotion: {
      type: "boolean" as const,
      default: false,
      description:
        "Static mark and complete statements, without camera motion or blur",
    },
  },
  durationInFrames: RELEASE_TEASER_FRAMES,
  fps: RELEASE_TEASER_FPS,
  compositionWidth: RELEASE_TEASER_WIDTH,
  compositionHeight: RELEASE_TEASER_HEIGHT,
};
registry/remocn-templates/release-teaser/content.ts
export interface ReleaseTeaserTheme {
  background: string;
  surface: string;
  foreground: string;
  muted: string;
  accent: string;
}

export interface ReleaseTeaserProps {
  brandName?: string;
  release?: string;
  tagline?: string;
  statements?: string[];
  accentColor?: string;
  theme?: Partial<ReleaseTeaserTheme>;
  logoSrc?: string;
  lightIntensity?: number;
  reducedMotion?: boolean;
  audioSrc?: string;
  volume?: number;
}

export const releaseTeaserTheme: ReleaseTeaserTheme = {
  background: "#050709",
  surface: "#151C22",
  foreground: "#F2F5F7",
  muted: "#A4ADB5",
  accent: "#9BC8DD",
};

export const releaseTeaserStatements = [
  "Start with an idea",
  "Make room\nfor better work",
  "Bring every detail to life",
  "Move together.\nGo further.",
  "A fresh chapter.\nNo starting over.",
] as const;

const nonempty = (value: string | undefined, fallback: string) =>
  value?.trim() || fallback;

export function resolveReleaseTeaserProps(props: ReleaseTeaserProps = {}) {
  return {
    brandName: nonempty(props.brandName, "Orvio"),
    // An explicit empty release intentionally hides the version suffix.
    release: props.release === undefined ? "2" : props.release.trim(),
    tagline: nonempty(props.tagline, "Your next chapter starts here."),
    statements: releaseTeaserStatements.map((fallback, i) =>
      nonempty(props.statements?.[i], fallback),
    ),
    theme: {
      background: nonempty(
        props.theme?.background,
        releaseTeaserTheme.background,
      ),
      surface: nonempty(props.theme?.surface, releaseTeaserTheme.surface),
      foreground: nonempty(
        props.theme?.foreground,
        releaseTeaserTheme.foreground,
      ),
      muted: nonempty(props.theme?.muted, releaseTeaserTheme.muted),
      accent: nonempty(
        props.accentColor,
        nonempty(props.theme?.accent, releaseTeaserTheme.accent),
      ),
    },
    logoSrc: props.logoSrc?.trim() || undefined,
    lightIntensity: Number.isFinite(props.lightIntensity)
      ? Math.max(0, Math.min(2, props.lightIntensity ?? 1))
      : 1,
    reducedMotion: props.reducedMotion ?? false,
  };
}

export type ReleaseTeaserScene = ReturnType<typeof resolveReleaseTeaserProps>;
registry/remocn-templates/release-teaser/motion.ts
import { Easing, interpolate } from "remotion";

export const RELEASE_TEASER_FPS = 60000 / 1001;
export const RELEASE_TEASER_FRAMES = 960;
export const RELEASE_TEASER_WIDTH = 1920;
export const RELEASE_TEASER_HEIGHT = 1080;

// Half-open intervals on a doubled NTSC picture clock (16.016 seconds).
export const releaseTeaserTimeline = [
  { name: "An idea", from: 0, to: 104 },
  { name: "Better work", from: 104, to: 208 },
  { name: "Every detail", from: 208, to: 326 },
  { name: "Together", from: 326, to: 446 },
  { name: "A fresh chapter", from: 446, to: 610 },
  { name: "Release reveal", from: 610, to: 960 },
] as const;

export const atFps = (frame: number, fps: number) =>
  Math.round((frame / RELEASE_TEASER_FPS) * fps);
export const clamp = (value: number) => Math.max(0, Math.min(1, value));
export const progress = (frame: number, from: number, to: number) =>
  clamp((frame - from) / (to - from));
export const ease = (frame: number, from: number, to: number) =>
  interpolate(frame, [from, to], [0, 1], {
    easing: Easing.bezier(0.5, 0, 0.2, 1),
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
export const graphemes = (text: string) =>
  Array.from(segmenter.segment(text), (part) => part.segment);

export function textUnits(text: string) {
  return graphemes(text).reduce((sum, char) => {
    if (/\s/.test(char)) return sum + 0.3;
    if (/[ilI.,'!|]/.test(char)) return sum + 0.29;
    if (/[MW@%]/.test(char)) return sum + 0.98;
    return sum + ((char.codePointAt(0) ?? 0) > 0x024f ? 1.02 : 0.63);
  }, 0);
}

/** Honor explicit lines; keep arbitrary overrides within the safe area. */
export function fitText(text: string, maxSize = 48, width = 808) {
  const lines = text.split("\n");
  return Math.min(
    maxSize,
    width / Math.max(1, ...lines.map(textUnits)),
    220 / (Math.max(1, lines.length) * 1.15),
  );
}

/** Center-out focus, with a bounded stagger independent of copy length. */
export function characterState(
  frame: number,
  end: number,
  index: number,
  count: number,
) {
  const distance =
    count <= 1 ? 0 : Math.abs(index - (count - 1) / 2) / ((count - 1) / 2);
  const delay = distance * 7;
  const enter = ease(frame, delay, delay + 18);
  const exit = ease(frame, end - 16 - distance * 5, end - 1 - distance * 5);
  return {
    opacity: enter * (1 - exit),
    blur: (1 - enter) * 5 + exit * 3,
    y: (1 - enter) * 2 - exit * 1.5,
  };
}

export function ringPose(frame: number, reducedMotion = false) {
  const f = reducedMotion ? 760 : Math.max(0, Math.min(959, frame));
  const travel = progress(f, 0, 610);
  const reveal = ease(f, 602, 702);
  return {
    x: 480 + (1 - reveal) * (18 - travel * 26),
    y: (-200 + travel * 100) * (1 - reveal) + 270 * reveal,
    scale: (2.78 - travel * 0.32) * (1 - reveal) + 1.08 * reveal,
    rx: (0.4 - travel * 0.1) * (1 - reveal) + 0.14 * reveal,
    ry: (-0.55 + travel * 0.12) * (1 - reveal) - 0.16 * reveal,
    rz: (-0.42 + travel * 0.22) * (1 - reveal) - 0.13 * reveal,
    lightAngle: -0.8 + (f / 960) * 1.1,
    opacity: reducedMotion ? 0.72 : ease(f, 0, 48) * (1 - ease(f, 858, 946)),
  };
}
registry/remocn-templates/release-teaser/geometry.ts
export type Vec3 = [number, number, number];
export interface RingFace {
  vertices: Vec3[];
  normal: Vec3;
  center: Vec3;
  bevel: boolean;
  edgeNormals?: [Vec3, Vec3];
}

const normalize = (v: Vec3): Vec3 => {
  const length = Math.hypot(...v) || 1;
  return [v[0] / length, v[1] / length, v[2] / length];
};
const subtract = (a: Vec3, b: Vec3): Vec3 => [
  a[0] - b[0],
  a[1] - b[1],
  a[2] - b[2],
];
const cross = (a: Vec3, b: Vec3): Vec3 => [
  a[1] * b[2] - a[2] * b[1],
  a[2] * b[0] - a[0] * b[2],
  a[0] * b[1] - a[1] * b[0],
];
export const dot = (a: Vec3, b: Vec3) =>
  a[0] * b[0] + a[1] * b[1] + a[2] * b[2];

function face(vertices: Vec3[], bevel: boolean): RingFace {
  return {
    vertices,
    normal: normalize(
      cross(
        subtract(vertices[1], vertices[0]),
        subtract(vertices[2], vertices[0]),
      ),
    ),
    center: vertices.reduce<Vec3>(
      (s, p) => [
        s[0] + p[0] / vertices.length,
        s[1] + p[1] / vertices.length,
        s[2] + p[2] / vertices.length,
      ],
      [0, 0, 0],
    ),
    bevel,
  };
}

/** A solid 320-degree annulus with a 40-degree opening and eight chamfered edges. */
export function createRing(segments = 96): RingFace[] {
  const count = Math.max(12, Math.round(segments));
  // Counter-clockwise cross-section: radius, depth. Front faces point toward +Z.
  const profile = [
    [144, 32],
    [210, 32],
    [220, 22],
    [220, -22],
    [210, -32],
    [144, -32],
    [134, -22],
    [134, 22],
  ];
  const section = (i: number): Vec3[] => {
    const angle = ((-18 + (i / count) * 320) * Math.PI) / 180;
    return profile.map(([r, z]) => [
      r * Math.cos(angle),
      r * Math.sin(angle),
      z,
    ]);
  };
  const faces: RingFace[] = [];
  for (let i = 0; i < count; i++) {
    const a = section(i);
    const b = section(i + 1);
    for (let j = 0; j < profile.length; j++) {
      const next = (j + 1) % profile.length;
      const quad = face([a[j], a[next], b[next], b[j]], j % 2 === 1);
      const halfStep = ((160 / count) * Math.PI) / 180;
      // Continuous circumferential normals preserve the flat chamfers while
      // allowing the renderer to shade each strip without visible tessellation.
      quad.edgeNormals = [
        rotate(quad.normal, 0, 0, -halfStep),
        rotate(quad.normal, 0, 0, halfStep),
      ];
      faces.push(quad);
    }
  }
  faces.push(face(section(0).reverse(), false), face(section(count), false));
  return faces;
}

export function rotate(v: Vec3, rx: number, ry: number, rz: number): Vec3 {
  const y = v[1] * Math.cos(rx) - v[2] * Math.sin(rx);
  const z = v[1] * Math.sin(rx) + v[2] * Math.cos(rx);
  const x = v[0] * Math.cos(ry) + z * Math.sin(ry);
  const depth = -v[0] * Math.sin(ry) + z * Math.cos(ry);
  return [
    x * Math.cos(rz) - y * Math.sin(rz),
    x * Math.sin(rz) + y * Math.cos(rz),
    depth,
  ];
}

/** Compute trigonometry once for a whole frame, not for every mesh vertex. */
export function createRotator(rx: number, ry: number, rz: number) {
  const x = rotate([1, 0, 0], rx, ry, rz);
  const y = rotate([0, 1, 0], rx, ry, rz);
  const z = rotate([0, 0, 1], rx, ry, rz);
  return (v: Vec3): Vec3 => [
    x[0] * v[0] + y[0] * v[1] + z[0] * v[2],
    x[1] * v[0] + y[1] * v[1] + z[1] * v[2],
    x[2] * v[0] + y[2] * v[1] + z[2] * v[2],
  ];
}

export function project(
  v: Vec3,
  scale: number,
  x: number,
  y: number,
): [number, number] {
  const perspective = 1100 / Math.max(300, 1100 - v[2] * scale);
  return [x + v[0] * scale * perspective, y + v[1] * scale * perspective];
}

export { normalize };
registry/remocn-templates/release-teaser/background.tsx
import { useLayoutEffect, useRef } from "react";
import type { ReleaseTeaserScene } from "./content";
import {
  createRing,
  createRotator,
  dot,
  normalize,
  project,
  type Vec3,
} from "./geometry";
import { ringPose } from "./motion";
import { BrandImage } from "./ui";

const ring = createRing();

/** Resolve any browser CSS color to channels without bundling a color library. */
function channels(ctx: CanvasRenderingContext2D, color: string): number[] {
  ctx.clearRect(0, 0, 1, 1);
  ctx.fillStyle = "#000000";
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3);
}

function DimensionalRing({
  scene,
  frame,
}: {
  scene: ReleaseTeaserScene;
  frame: number;
}) {
  const canvas = useRef<HTMLCanvasElement>(null);
  const colorCache = useRef(new Map<string, number[]>());
  // Synchronous layout paint: a seek paints only its requested frame, with no RAF or history.
  useLayoutEffect(() => {
    const ctx = canvas.current?.getContext("2d", { willReadFrequently: true });
    if (!ctx) return;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    const colorChannels = (color: string) => {
      const cached = colorCache.current.get(color);
      if (cached) return cached;
      const value = channels(ctx, color);
      colorCache.current.set(color, value);
      return value;
    };
    const accent = colorChannels(scene.theme.accent);
    const surface = colorChannels(scene.theme.surface);
    ctx.clearRect(0, 0, 1920, 1080);
    ctx.scale(2, 2);
    const pose = ringPose(frame, scene.reducedMotion);
    const rotatePoint = createRotator(pose.rx, pose.ry, pose.rz);
    const key = normalize([Math.cos(pose.lightAngle) * 0.95, 0.6, 0.3]);
    const rim = normalize([-0.8, -0.5, 0.18]);
    const half = normalize([key[0], key[1], key[2] + 1]);
    const faces = ring
      .map((face) => ({
        ...face,
        normal: rotatePoint(face.normal),
        edgeNormals: face.edgeNormals?.map(rotatePoint),
        center: rotatePoint(face.center),
        vertices: face.vertices.map(rotatePoint),
      }))
      .sort((a, b) => a.center[2] - b.center[2]);
    for (const face of faces) {
      if (
        dot(
          face.normal,
          normalize([
            -face.center[0],
            -face.center[1],
            1100 / pose.scale - face.center[2],
          ]),
        ) <= 0
      )
        continue;
      const shade = (normal: Vec3) => {
        const diffuse = Math.max(0, dot(normal, key));
        const rimLight = Math.max(0, dot(normal, rim));
        const specular =
          Math.max(0, dot(normal, half)) ** (face.bevel ? 26 : 48);
        const intensity = scene.lightIntensity * pose.opacity;
        const rgb = surface.map((c, i) =>
          Math.min(
            255,
            c * 0.19 +
              intensity *
                (accent[i] * (diffuse ** 3 * 0.36 + rimLight ** 6 * 0.16) +
                  180 * specular * 0.68),
          ),
        );
        return `rgb(${rgb.join(",")})`;
      };
      const points = face.vertices.map((v) =>
        project(v, pose.scale, pose.x, pose.y),
      );
      ctx.beginPath();
      ctx.moveTo(...points[0]);
      for (const p of points.slice(1)) ctx.lineTo(...p);
      ctx.closePath();
      if (face.edgeNormals) {
        const gradient = ctx.createLinearGradient(
          (points[0][0] + points[1][0]) / 2,
          (points[0][1] + points[1][1]) / 2,
          (points[2][0] + points[3][0]) / 2,
          (points[2][1] + points[3][1]) / 2,
        );
        gradient.addColorStop(0, shade(face.edgeNormals[0]));
        gradient.addColorStop(1, shade(face.edgeNormals[1]));
        ctx.fillStyle = gradient;
      } else {
        ctx.fillStyle = shade(face.normal);
      }
      ctx.fill();
      // A sub-pixel seam overlap prevents hairlines between adjoining mesh quads.
      ctx.strokeStyle = ctx.fillStyle;
      ctx.lineWidth = 0.45;
      ctx.stroke();
    }
  }, [
    frame,
    scene.reducedMotion,
    scene.lightIntensity,
    scene.theme.accent,
    scene.theme.surface,
  ]);

  return (
    <canvas
      ref={canvas}
      width={1920}
      height={1080}
      tabIndex={-1}
      aria-hidden="true"
      style={{ width: 960, height: 540, position: "absolute", inset: 0 }}
    />
  );
}

export function TeaserBackground({
  scene,
  frame,
}: {
  scene: ReleaseTeaserScene;
  frame: number;
}) {
  const pose = ringPose(frame, scene.reducedMotion);
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        opacity: pose.opacity,
      }}
    >
      {scene.logoSrc ? (
        <div
          style={{
            position: "absolute",
            left: pose.x - 220,
            top: pose.y - 220,
            width: 440,
            height: 440,
            scale: pose.scale,
            rotate: `${(pose.rz * 180) / Math.PI}deg`,
            opacity: 0.18 * scene.lightIntensity,
          }}
        >
          <BrandImage
            src={scene.logoSrc}
            style={{ width: "100%", height: "100%", objectFit: "contain" }}
          />
        </div>
      ) : (
        <DimensionalRing scene={scene} frame={frame} />
      )}
    </div>
  );
}
registry/remocn-templates/release-teaser/ui.tsx
import type { CSSProperties } from "react";
import { Img, useRemotionEnvironment } from "remotion";

export function BrandImage({
  src,
  style,
}: {
  src: string;
  style: CSSProperties;
}) {
  const { isStudio, isRendering } = useRemotionEnvironment();
  if (isStudio && !isRendering) {
    // biome-ignore lint/performance/noImgElement: Studio-only media registration workaround; exports and embedded players retain Img's loading gate.
    return <img src={src} alt="" style={style} />;
  }
  return <Img src={src} style={style} />;
}

/** Original open-orbit mark; the opening matches the dimensional background. */
export function OrbitMark({
  color,
  size = 30,
}: {
  color: string;
  size?: number;
}) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="-240 -240 480 480"
      fill="none"
      aria-hidden="true"
    >
      <path
        d="M209.23 -67.98 A220 220 0 1 1 116.58 -186.57 L71.01 -113.64 A134 134 0 1 0 127.44 -41.41 Z"
        fill={color}
      />
    </svg>
  );
}
registry/remocn-templates/release-teaser/scenes/statement.tsx
import { Interactive, interpolate } from "remotion";
import type { ReleaseTeaserScene } from "../content";
import { characterState, fitText, graphemes } from "../motion";

export function Statement({
  scene,
  text,
  frame,
  duration,
}: {
  scene: ReleaseTeaserScene;
  text: string;
  frame: number;
  duration: number;
}) {
  return (
    <Interactive.Div
      name="Statement"
      role="img"
      aria-label={text}
      style={{
        position: "absolute",
        inset: 0,
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center",
        fontSize: fitText(text),
        fontWeight: 500,
        lineHeight: 1.13,
        color: scene.theme.foreground,
        scale: scene.reducedMotion
          ? 1
          : interpolate(frame, [0, duration], [0.99, 1.025], {
              extrapolateLeft: "clamp",
              extrapolateRight: "clamp",
            }),
      }}
    >
      {text.split("\n").map((line, lineIndex) => {
        const chars = graphemes(line);
        return (
          <div
            key={`${lineIndex}-${line}`}
            style={{
              whiteSpace: "pre",
              textAlign: "center",
              minHeight: "1.13em",
            }}
          >
            {scene.reducedMotion
              ? line
              : chars.map((char, index) => {
                  const state = characterState(
                    frame,
                    duration,
                    index,
                    chars.length,
                  );
                  return (
                    <span
                      key={`${index}-${char}`}
                      style={{
                        display: "inline-block",
                        opacity: state.opacity,
                        filter:
                          state.blur > 0.08
                            ? `blur(${state.blur}px)`
                            : undefined,
                        translate: `0 ${state.y}px`,
                      }}
                    >
                      {char}
                    </span>
                  );
                })}
          </div>
        );
      })}
    </Interactive.Div>
  );
}
registry/remocn-templates/release-teaser/scenes/closing.tsx
import { Interactive, interpolate } from "remotion";
import type { ReleaseTeaserScene } from "../content";
import { ease, fitText } from "../motion";
import { BrandImage, OrbitMark } from "../ui";

export function ReleaseLockup({
  scene,
  frame,
}: {
  scene: ReleaseTeaserScene;
  frame: number;
}) {
  const title = `${scene.brandName}${scene.release ? ` ${scene.release}` : ""}`;
  const reveal = scene.reducedMotion ? 1 : ease(frame, 0, 32);
  return (
    <Interactive.Div
      name="Release lockup"
      style={{
        position: "absolute",
        inset: 0,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        opacity: reveal,
        filter:
          !scene.reducedMotion && reveal < 0.99
            ? `blur(${(1 - reveal) * 6}px)`
            : undefined,
        scale: scene.reducedMotion
          ? 1
          : interpolate(frame, [0, 350], [0.97, 1.03], {
              extrapolateLeft: "clamp",
              extrapolateRight: "clamp",
            }),
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 16,
          marginBottom: 16,
        }}
      >
        <div style={{ width: 34, height: 34, flexShrink: 0 }}>
          {scene.logoSrc ? (
            <BrandImage
              src={scene.logoSrc}
              style={{ width: 34, height: 34, objectFit: "contain" }}
            />
          ) : (
            <OrbitMark size={34} color={scene.theme.accent} />
          )}
        </div>
        <Interactive.Div
          name="Brand and release"
          style={{
            fontSize: fitText(title, 70, 750),
            fontWeight: 600,
            lineHeight: 1,
            color: scene.theme.foreground,
            whiteSpace: "pre",
            textAlign: "center",
          }}
        >
          {title}
        </Interactive.Div>
      </div>
      <Interactive.Div
        name="Closing line"
        style={{
          fontSize: fitText(scene.tagline, 20, 740),
          lineHeight: 1.3,
          color: scene.theme.muted,
          whiteSpace: "pre",
          textAlign: "center",
          opacity: scene.reducedMotion ? 1 : ease(frame, 8, 40),
        }}
      >
        {scene.tagline}
      </Interactive.Div>
    </Interactive.Div>
  );
}

More from Remocn

Browse all components in this library →