← Catalog
Edit Props (live)
IMAGE
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
config
config
Source
712 linesimport 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", "500", "700", "900"] });
type TopMode = "scroll" | "static" | "logo_reveal" | "cursor_interact" | "fullscreen";
interface SplitHeadProps {
topMode: TopMode;
topSrc?: string; // screenshot or long list asset
topLogo?: string; // logo image (for logo_reveal)
topWordmark?: string; // wordmark text (for logo_reveal)
topTilt?: number; // static tilt degrees, default -2
topScrollAmt?: number; // override scroll speed (0..1)
cursorTarget?: { x: number; y: number }; // 0..1 normalized, for cursor_interact
backgroundColor?: string;
// Optional brand-theme overrides for logo_reveal top-mode
topBackgroundColor?: string; // overrides top panel bg (e.g. "#0d0d0f" for Anthropic)
topAccentColor?: string; // overrides ambient glow + accent tint
topWordmarkColor?: string; // overrides the wordmark text color
captionText?: string;
captionStyle?: "chip" | "magenta_outline";
headSrc?: string | null; // talking-head video
headStartFrame?: number; // seek-into for rach_head.mp4 so lips sync with audio
}
const FONT = "'Inter', sans-serif";
const isVideo = (s: string) => /\.(mp4|mov|webm)(\?|$)/i.test(s);
const resolveStatic = (s: string) =>
s.startsWith("http") ? s : staticFile(s);
export const SplitHead: React.FC<SplitHeadProps> = ({
topMode,
topSrc,
topLogo,
topWordmark,
topTilt = -2,
topScrollAmt,
cursorTarget = { x: 0.5, y: 0.6 },
backgroundColor = "#f6f4ed",
topBackgroundColor,
topAccentColor,
topWordmarkColor,
captionText,
captionStyle = "chip",
headSrc,
headStartFrame,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const captionOp = interpolate(frame, [10, 22], [0, 1], { extrapolateRight: "clamp" });
const captionY = interpolate(frame, [10, 22], [12, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
// "fullscreen" top mode takes over the whole frame; no head, no seam.
if (topMode === "fullscreen" && topSrc) {
return (
<AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
<FullscreenTop src={topSrc} backgroundColor={backgroundColor} />
{captionText && (
<div
style={{
position: "absolute",
bottom: 180,
left: 0,
right: 0,
opacity: captionOp,
transform: `translateY(${captionY}px)`,
display: "flex",
justifyContent: "center",
zIndex: 20,
pointerEvents: "none",
}}
>
<CaptionChip text={captionText} variant={captionStyle} />
</div>
)}
</AbsoluteFill>
);
}
return (
<AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
{/* Top half */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
overflow: "hidden",
background: backgroundColor,
}}
>
{topMode === "scroll" && topSrc && <ScrollingTop src={topSrc} override={topScrollAmt} />}
{topMode === "static" && topSrc && <StaticTop src={topSrc} tilt={topTilt} />}
{topMode === "logo_reveal" && (
<LogoRevealTop
logo={topLogo}
wordmark={topWordmark}
backgroundColor={topBackgroundColor}
accentColor={topAccentColor}
wordmarkColor={topWordmarkColor}
/>
)}
{topMode === "cursor_interact" && topSrc && (
<CursorInteractTop src={topSrc} target={cursorTarget} />
)}
</div>
{/* Bottom half — talking head */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50%",
overflow: "hidden",
background: "#1a0b24",
}}
>
{headSrc ? (
isVideo(headSrc) ? (
<OffthreadVideo
src={resolveStatic(headSrc)}
muted
startFrom={headStartFrame}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
) : (
<Img
src={resolveStatic(headSrc)}
style={{
width: "100%", height: "100%",
objectFit: "cover", objectPosition: "center -25px",
display: "block",
}}
/>
)
) : (
<HeadPlaceholder />
)}
</div>
{/* Seam divider glow */}
<div
style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
height: 3,
transform: "translateY(-1.5px)",
background: "rgba(255,255,255,0.18)",
boxShadow: "0 0 14px rgba(255,255,255,0.28)",
pointerEvents: "none",
zIndex: 8,
}}
/>
{/* Caption — sits at the seam */}
{captionText && (
<div
style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
transform: `translateY(calc(-50% + ${captionY}px))`,
opacity: captionOp,
display: "flex",
justifyContent: "center",
zIndex: 20,
pointerEvents: "none",
}}
>
<CaptionChip text={captionText} variant={captionStyle} />
</div>
)}
</AbsoluteFill>
);
};
// ─── Top modes ────────────────────────────────────────────────
const ScrollingTop: React.FC<{ src: string; override?: number }> = ({ src, override }) => {
const frame = useCurrentFrame();
const { durationInFrames, width, height } = useVideoConfig();
// We render the source PNG at the SPLIT-HALF width (1080), so its natural
// height = (imgH/imgW) * 1080. We must know the image's intrinsic ratio
// before scaling — but at render time we don't have it. Instead, we render
// the image at width=100% and read its DOM height via inline style. Since
// <Img> here scales by width to 100% of container (1080), we use a wrapper
// that translates the image upward by `scroll` pixels. The image's natural
// height is preserved when width is fixed.
const halfH = height / 2; // top half of the 9:16 frame (e.g. 960)
// Easing-style accelerating sweep. We want to move FAST to sell motion.
const amt = override ??
interpolate(frame, [4, durationInFrames - 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const resolved = resolveStatic(src);
const opIn = 1; // load instantly — no fade-in
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#0a0612", opacity: opIn }}>
<Img
src={resolved}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "auto",
display: "block",
// translateY relative to wrapper height (halfH). The image renders
// at width 1080 → its natural height. We translate by -amt * (imgH - halfH).
// We don't have imgH at compile time, so use a CSS calc with the
// scroll factor expressed as a percentage of the IMAGE's own height
// via translate(0, -(amt*(imgH-halfH))). For unknown imgH, use
// calc + a generous distance: shift by amt * (-100% + halfH px).
// (-100% means "shift up by image's own height", so adding halfH
// back keeps the bottom edge at the container bottom at amt=1.)
transform: `translateY(calc(${(-amt * 100).toFixed(2)}% + ${(amt * halfH).toFixed(2)}px))`,
willChange: "transform",
}}
/>
{/* Bottom fade into seam */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 80,
background: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.45))",
pointerEvents: "none",
zIndex: 2,
}}
/>
{/* Top fade for seamless edge */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 60,
background: "linear-gradient(to top, transparent, rgba(0,0,0,0.55))",
pointerEvents: "none",
zIndex: 2,
}}
/>
</div>
);
};
const StaticTop: React.FC<{ src: string; tilt: number }> = ({ src, tilt }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Asset loads instantly — no fade-in so the bg doesn't flash through
// before the screenshot appears.
const opIn = 1;
// Slow ken-burns on a full-bleed image so there's always motion + full coverage
const progress = frame / Math.max(1, durationInFrames - 1);
const scale = 1.05 + progress * 0.08;
const driftX = Math.sin((frame / fps) * 0.6) * 1.5;
const resolved = resolveStatic(src);
const video = isVideo(src);
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
opacity: opIn,
background: "#000",
}}
>
{video ? (
<OffthreadVideo
src={resolved}
muted
style={{
width: "100%",
height: "100%",
objectFit: "contain",
display: "block",
background: "#f6f4ed",
transform: `scale(${scale}) translateX(${driftX}%) rotate(${tilt * 0.3}deg)`,
}}
/>
) : (
<Img
src={resolved}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
display: "block",
background: "#f6f4ed",
transform: `scale(${scale}) translateX(${driftX}%) rotate(${tilt * 0.3}deg)`,
}}
/>
)}
{/* Top gradient for polish */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 80,
background: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.4))",
pointerEvents: "none",
}}
/>
</div>
);
};
// Full-frame asset — used when a 16:9 video should headline the scene without the
// split. The video is laid out as a true 16:9 panel anchored toward the upper third
// of the 9:16 frame (eye level for phone viewing) so the bottom-aligned kinetic
// captions don't overlap it.
const FullscreenTop: React.FC<{ src: string; backgroundColor: string }> = ({ src, backgroundColor }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const opIn = 1; // load instantly — no fade-in
const progress = frame / Math.max(1, durationInFrames - 1);
const scale = 1.02 + progress * 0.04;
const driftX = Math.sin((frame / fps) * 0.6) * 0.6;
const resolved = resolveStatic(src);
const video = isVideo(src);
// 16:9 panel sized to the canvas width, parked so it ends ~10px above the
// centered body-caption band (caption is at top:50% with translateY(-50%)).
const panelWidth = width;
const panelHeight = Math.round(panelWidth * 9 / 16);
const panelTop = Math.round(height * 0.15);
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", background: backgroundColor }}>
<div
style={{
position: "absolute",
top: panelTop,
left: 0,
width: panelWidth,
height: panelHeight,
opacity: opIn,
background: "#000",
overflow: "hidden",
transform: `scale(${scale}) translateX(${driftX}%)`,
transformOrigin: "center center",
}}
>
{video ? (
<OffthreadVideo
src={resolved}
muted
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
) : (
<Img
src={resolved}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
)}
</div>
</div>
);
};
// Parse "#RRGGBB" → "r, g, b" for use inside rgba().
const hexToRgb = (hex: string): string => {
const clean = hex.replace("#", "");
const full = clean.length === 3
? clean.split("").map((c) => c + c).join("")
: clean;
const n = parseInt(full, 16);
if (Number.isNaN(n)) return "120, 90, 255";
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
};
const LogoRevealTop: React.FC<{
logo?: string;
wordmark?: string;
backgroundColor?: string;
accentColor?: string;
wordmarkColor?: string;
}> = ({ logo, wordmark, backgroundColor, accentColor, wordmarkColor }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoSpring = spring({
frame,
fps,
config: { mass: 1.1, damping: 12, stiffness: 130 },
});
const logoScale = interpolate(logoSpring, [0, 1], [0.4, 1]);
const logoOp = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: "clamp" });
const wordStart = 12;
const wordChars = wordmark?.split("") || [];
const wordCps = 18;
const revealCount = Math.max(0, Math.floor(((frame - wordStart) / fps) * wordCps));
const revealed = wordChars.slice(0, revealCount).join("");
const glowPulse = 0.7 + Math.sin((frame / fps) * 2.5) * 0.15;
// Wordmark font size scales with character count so it fits width ≈ 700px
const wm = wordmark || "";
const wordFontSize = wm.length > 0 ? Math.min(160, Math.floor(720 / Math.max(4, wm.length)) + 40) : 140;
const bg = backgroundColor || "#f6f4ed";
const accent = accentColor || "#7a5aff";
const wordColor = wordmarkColor || (backgroundColor ? "#ffffff" : "#111");
const accentRgb = hexToRgb(accent);
const bgRgb = hexToRgb(bg);
return (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
gap: 28,
padding: 40,
background:
`radial-gradient(ellipse at 50% 40%, rgba(${accentRgb}, 0.18) 0%, rgba(${bgRgb}, 1) 70%)`,
}}
>
{/* Accent glow behind */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 900,
height: 900,
transform: "translate(-50%, -50%)",
background: `radial-gradient(circle, rgba(${accentRgb}, ${0.22 * glowPulse}) 0%, transparent 60%)`,
pointerEvents: "none",
filter: "blur(50px)",
}}
/>
{logo && (
<Img
src={resolveStatic(logo)}
style={{
width: 280,
height: 280,
objectFit: "contain",
borderRadius: 56,
transform: `scale(${logoScale})`,
opacity: logoOp,
filter: "drop-shadow(0 12px 30px rgba(0,0,0,0.45))",
}}
/>
)}
{wordmark && (
<div
style={{
fontFamily: FONT,
fontSize: wordFontSize,
fontWeight: 900,
color: wordColor,
letterSpacing: -3,
opacity: logoOp,
lineHeight: 1,
textAlign: "center",
}}
>
{revealed}
<span
style={{
opacity: revealCount < wordChars.length ? (frame % 20 < 10 ? 1 : 0) : 0,
fontWeight: 400,
}}
>
|
</span>
</div>
)}
</div>
);
};
const CursorInteractTop: React.FC<{ src: string; target: { x: number; y: number } }> = ({
src,
target,
}) => {
const frame = useCurrentFrame();
const resolved = resolveStatic(src);
// Cursor glides from top-right to target, then clicks
const glideT = interpolate(frame, [4, 32], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const startX = 0.92;
const startY = 0.08;
const cursorX = startX + (target.x - startX) * glideT;
const cursorY = startY + (target.y - startY) * glideT;
const clickFrame = 34;
const clickT = interpolate(frame, [clickFrame, clickFrame + 22], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const clickScale = 0.8 + clickT * 2.6;
const clickOp = interpolate(clickT, [0, 0.35, 1], [0.75, 0.75, 0]);
const shotOp = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: "clamp" });
// Slight parallax zoom toward the cursor target to suggest "interaction focus"
const zoom = 1 + interpolate(frame, [0, 60], [0, 0.04], { extrapolateRight: "clamp" });
const transformOrigin = `${target.x * 100}% ${target.y * 100}%`;
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#0a0a14" }}>
<Img
src={resolved}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center center",
opacity: shotOp,
transform: `scale(${zoom})`,
transformOrigin,
}}
/>
{/* Halo pulse under target (visible before click lands) */}
<div
style={{
position: "absolute",
left: `${target.x * 100}%`,
top: `${target.y * 100}%`,
width: 120,
height: 120,
marginLeft: -60,
marginTop: -60,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(80,154,219,0.35) 0%, transparent 70%)",
opacity: interpolate(frame, [20, 32], [0, 0.9], { extrapolateRight: "clamp" }),
pointerEvents: "none",
filter: "blur(6px)",
}}
/>
{/* Click ripple */}
<div
style={{
position: "absolute",
left: `${target.x * 100}%`,
top: `${target.y * 100}%`,
width: 100,
height: 100,
marginLeft: -50,
marginTop: -50,
borderRadius: "50%",
border: "3px solid rgba(80, 154, 219, 0.95)",
transform: `scale(${clickScale})`,
opacity: clickOp,
pointerEvents: "none",
}}
/>
{/* Cursor */}
<svg
width="46"
height="60"
viewBox="0 0 34 44"
style={{
position: "absolute",
left: `${cursorX * 100}%`,
top: `${cursorY * 100}%`,
transform: "translate(-6px, -3px)",
filter: "drop-shadow(0 3px 5px rgba(0,0,0,0.55))",
zIndex: 5,
}}
>
<path
d="M4 4 L4 32 L11 26 L16 38 L20 36 L15 24 L24 24 Z"
fill="#fff"
stroke="#000"
strokeWidth="1.5"
/>
</svg>
</div>
);
};
const HeadPlaceholder: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pulse = 0.6 + Math.sin((frame / fps) * 1.2) * 0.08;
// Ambient moody gradient — no text so the empty frame reads as "studio lighting" not "missing asset"
return (
<div
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse at 50% 45%, rgba(140, 90, 220, 0.45) 0%, rgba(20, 8, 30, 1) 65%)",
}}
>
{/* Soft light columns suggesting purple LED behind a creator */}
<div
style={{
position: "absolute",
left: "12%",
top: "8%",
bottom: "8%",
width: 14,
background: `linear-gradient(to bottom, rgba(180,120,255,${0.5 * pulse}) 0%, rgba(180,120,255,${0.2 * pulse}) 100%)`,
filter: "blur(18px)",
borderRadius: 10,
}}
/>
<div
style={{
position: "absolute",
right: "12%",
top: "12%",
bottom: "12%",
width: 14,
background: `linear-gradient(to bottom, rgba(180,120,255,${0.35 * pulse}) 0%, rgba(180,120,255,${0.5 * pulse}) 100%)`,
filter: "blur(18px)",
borderRadius: 10,
}}
/>
{/* Central orb soft bloom where the face would be */}
<div
style={{
position: "absolute",
top: "48%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 360,
height: 360,
borderRadius: "50%",
background: `radial-gradient(circle, rgba(240,220,255,${0.18 * pulse}) 0%, transparent 70%)`,
filter: "blur(30px)",
}}
/>
</div>
);
};
const CaptionChip: React.FC<{ text: string; variant: "chip" | "magenta_outline" }> = ({
text,
variant,
}) => {
if (variant === "magenta_outline") {
return (
<div
style={{
fontFamily: FONT,
fontSize: 78,
fontWeight: 900,
color: "#ff3bd1",
WebkitTextStroke: "4px #000",
letterSpacing: -2,
textShadow: "3px 3px 0 #000, -3px 3px 0 #000, 3px -3px 0 #000, -3px -3px 0 #000",
}}
>
{text}
</div>
);
}
return (
<div
style={{
background: "rgba(0,0,0,0.8)",
color: "#fff",
padding: "16px 28px",
borderRadius: 14,
fontFamily: FONT,
fontSize: 46,
fontWeight: 700,
letterSpacing: -0.4,
}}
>
{text}
</div>
);
};
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: SplitHead
// 16 props detected · 3 port(s)
Split Head
split_headSplit Head — Andy's hand-crafted Remotion scene. 16 props. topLogo:IMAGE, captionText:TEXT, captionStyle:TEXT.
Workflow Inputs (3)
- Top Logo
topLogoIMAGE - Caption Text
captionTextTEXT - Caption Style
captionStyleTEXT
Config Fields (13)
ytopSrcheadSrctopModetopTilttopWordmarkcursorTargettopScrollAmtheadStartFrametopAccentColorbackgroundColortopWordmarkColortopBackgroundColor
Meta
- Updated
- 4/21/2026