Catalog

Edit Props (live)

TEXT
TEXT
config
config
config
config

Source

214 lines
import React from "react";
import {
  AbsoluteFill,
  Img,
  OffthreadVideo,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
  spring,
  staticFile,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";

loadInter("normal", { weights: ["400", "700", "900"] });

interface CenteredAssetProps {
  assetSrc: string;
  backgroundColor?: string;
  tilt?: number; // degrees, default 0 (straight)
  scroll?: boolean; // if true, asset is tall — auto-scroll vertically
  captionText?: string;
  captionStyle?: "chip" | "magenta_outline";
}

const FONT = "'Inter', sans-serif";

const isVideo = (src: string) => /\.(mp4|mov|webm)(\?|$)/i.test(src);

export const CenteredAsset: React.FC<CenteredAssetProps> = ({
  assetSrc,
  backgroundColor = "#f2eee4",
  tilt = 0,
  scroll = false,
  captionText,
  captionStyle = "chip",
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames, width, height } = useVideoConfig();

  const assetSpring = spring({ frame, fps, config: { mass: 1, damping: 14, stiffness: 110 } });
  const scaleIn = interpolate(assetSpring, [0, 1], [0.88, 1]);
  const opIn = interpolate(frame, [0, 12], [0, 1], { extrapolateRight: "clamp" });
  const floatY = Math.sin((frame / fps) * 1.1) * 6;

  const scrollAmt = interpolate(frame, [15, durationInFrames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });

  const captionOp = interpolate(frame, [14, 28], [0, 1], { extrapolateRight: "clamp" });
  const captionY = interpolate(frame, [14, 28], [16, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  const resolvedSrc = assetSrc.startsWith("http") ? assetSrc : staticFile(assetSrc);
  const video = isVideo(assetSrc);

  return (
    <AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
      {/* Subtle noise texture via radial gradient */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background: "transparent",
          pointerEvents: "none",
        }}
      />

      {/* Asset box — for videos, use a 16:9 panel parked at upper-third
          (eye level on a phone), so kinetic captions at the bottom don't
          overlap the demo. For images, keep the portrait card layout. */}
      {video ? (
        (() => {
          const panelWidth = width * 0.96;
          const panelHeight = panelWidth * 9 / 16;
          return (
            <div
              style={{
                position: "absolute",
                top: height * 0.15,
                left: (width - panelWidth) / 2,
                width: panelWidth,
                height: panelHeight,
                transform: `translateY(${floatY}px) rotate(${tilt}deg) scale(${scaleIn})`,
                opacity: opIn,
                borderRadius: 32,
                overflow: "hidden",
                boxShadow:
                  "0 50px 100px rgba(0,0,0,0.32), 0 20px 40px rgba(0,0,0,0.22), 0 0 0 1px rgba(0,0,0,0.06)",
                background: "#000",
              }}
            >
              <OffthreadVideo
                src={resolvedSrc}
                muted
                style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
              />
            </div>
          );
        })()
      ) : (
        <div
          style={{
            position: "absolute",
            top: "50%",
            left: "50%",
            transform: `translate(-50%, calc(-50% + ${floatY}px)) rotate(${tilt}deg) scale(${scaleIn})`,
            opacity: opIn,
            width: width * 0.88,
            height: height * 0.76,
            borderRadius: 32,
            overflow: "hidden",
            boxShadow:
              "0 50px 100px rgba(0,0,0,0.32), 0 20px 40px rgba(0,0,0,0.22), 0 0 0 1px rgba(0,0,0,0.06)",
            background: "#fff",
          }}
        >
          <Img
            src={resolvedSrc}
            style={{
              width: "100%",
              height: "100%",
              objectFit: "contain",
              objectPosition: scroll ? `center ${10 + scrollAmt * 80}%` : "center center",
              display: "block",
            }}
          />
        </div>
      )}

      {/* Caption */}
      {captionText && (
        <Caption
          text={captionText}
          style={captionStyle}
          opacity={captionOp}
          translateY={captionY}
          bottom={220}
        />
      )}
    </AbsoluteFill>
  );
};

const Caption: React.FC<{
  text: string;
  style: "chip" | "magenta_outline";
  opacity: number;
  translateY: number;
  bottom: number;
}> = ({ text, style, opacity, translateY, bottom }) => {
  if (style === "magenta_outline") {
    return (
      <div
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          bottom,
          textAlign: "center",
          fontFamily: FONT,
          fontSize: 88,
          fontWeight: 900,
          color: "#ff3bd1",
          WebkitTextStroke: "4px #000",
          letterSpacing: -2,
          textShadow:
            "0 6px 0 #000, 3px 3px 0 #000, -3px 3px 0 #000, 3px -3px 0 #000, -3px -3px 0 #000",
          opacity,
          transform: `translateY(${translateY}px)`,
          zIndex: 20,
        }}
      >
        {text}
      </div>
    );
  }
  return (
    <div
      style={{
        position: "absolute",
        left: 0,
        right: 0,
        bottom,
        display: "flex",
        justifyContent: "center",
        opacity,
        transform: `translateY(${translateY}px)`,
        zIndex: 20,
      }}
    >
      <div
        style={{
          background: "rgba(0,0,0,0.78)",
          color: "#fff",
          padding: "18px 32px",
          borderRadius: 16,
          fontFamily: FONT,
          fontSize: 54,
          fontWeight: 700,
          letterSpacing: -0.5,
          backdropFilter: "blur(6px)",
        }}
      >
        {text}
      </div>
    </div>
  );
};

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: CenteredAsset
// 6 props detected · 2 port(s)

Centered Asset

centered_asset

Centered Asset — Andy's hand-crafted Remotion scene. 6 props. captionText:TEXT, captionStyle:TEXT.

Workflow Inputs (2)

  • Caption Text
    captionText
    TEXT
  • Caption Style
    captionStyle
    TEXT

Config Fields (4)

  • tilt
  • scroll
  • assetSrc
  • backgroundColor

Meta

Updated
4/21/2026