← Catalog
Edit Props (live)
TEXT
config
config
config
config
config
config
config
config
config
config
Source
369 linesimport React from "react";
import {
AbsoluteFill,
Img,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
staticFile,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
loadInter("normal", { weights: ["400", "700", "900"] });
loadInter("italic", { weights: ["400", "900"] });
interface ComparisonSplitProps {
aSrc?: string;
bSrc?: string;
// Fallback solid/gradient fills when no image is provided (or preferred)
aBg?: string;
bBg?: string;
aLabel: string;
bLabel: string;
// Split direction
orientation?: "horizontal" | "vertical"; // horizontal = left/right, vertical = top/bottom
// Optional title overhead
title?: string;
// Draggable-looking divider style
showDivider?: boolean;
dividerColor?: string;
// Style: "reveal" = swipe reveal, "split" = both visible split, "versus" = both with VS in middle
mode?: "reveal" | "split" | "versus";
}
const FONT = "'Inter', sans-serif";
export const ComparisonSplit: React.FC<ComparisonSplitProps> = ({
aSrc,
bSrc,
aBg = "linear-gradient(135deg, #3a3a5c 0%, #1a1a2e 100%)",
bBg = "linear-gradient(135deg, #c94f3d 0%, #7a1f15 100%)",
aLabel,
bLabel,
orientation = "horizontal",
title,
showDivider = true,
dividerColor = "#ffffff",
mode = "split",
}) => {
const frame = useCurrentFrame();
const { durationInFrames, width, height } = useVideoConfig();
const { fps } = useVideoConfig();
// Side A fades in first, then side B a beat later
const aOp = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: "clamp" });
const bStartF = mode === "reveal" ? 20 : 8;
const bOp = interpolate(frame, [bStartF, bStartF + 14], [0, 1], { extrapolateRight: "clamp" });
// Reveal swipe position 0..1
const revealT = interpolate(frame, [20, 60], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
// Divider appears as B slides in
const dividerOp = interpolate(frame, [bStartF + 8, bStartF + 20], [0, 1], {
extrapolateRight: "clamp",
});
// Labels slide in with stagger
const aLabelOp = interpolate(frame, [15, 30], [0, 1], { extrapolateRight: "clamp" });
const aLabelX = interpolate(frame, [15, 30], [-40, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const bLabelOp = interpolate(frame, [bStartF + 12, bStartF + 27], [0, 1], {
extrapolateRight: "clamp",
});
const bLabelX = interpolate(frame, [bStartF + 12, bStartF + 27], [40, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
// VS badge in middle for "versus" mode
const vsOp = interpolate(frame, [24, 44], [0, 1], { extrapolateRight: "clamp" });
const vsScale = interpolate(frame, [24, 42], [0.5, 1], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.back(2)),
});
const vsPulse = 1 + Math.sin((frame / fps) * 2 * Math.PI) * 0.025;
// Title reveal (clipPath)
const titleReveal = interpolate(frame, [0, 22], [100, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
// Subtle zoom on each half to add motion
const progress = frame / Math.max(1, durationInFrames - 1);
const zoomA = interpolate(progress, [0, 1], [1.05, 1.1]);
const zoomB = interpolate(progress, [0, 1], [1.1, 1.05]);
const isHoriz = orientation === "horizontal";
// For reveal mode — B is revealed as a clip-path from one side
const revealClipPath = isHoriz
? `polygon(${revealT * 100}% 0%, 100% 0%, 100% 100%, ${revealT * 100}% 100%)`
: `polygon(0% ${revealT * 100}%, 100% ${revealT * 100}%, 100% 100%, 0% 100%)`;
return (
<AbsoluteFill style={{ backgroundColor: "#000", overflow: "hidden" }}>
{/* Side A — both "split" and "versus" show two halves; only "reveal" overlays full */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: mode !== "reveal" && isHoriz ? "50%" : "100%",
height: mode !== "reveal" && !isHoriz ? "50%" : "100%",
overflow: "hidden",
opacity: aOp,
background: aSrc ? "#000" : aBg,
}}
>
{aSrc && (
<Img
src={aSrc.startsWith("http") ? aSrc : staticFile(aSrc)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${zoomA})`,
}}
/>
)}
{/* Subtle dark gradient for label readability */}
<div
style={{
position: "absolute",
inset: 0,
background: isHoriz
? "linear-gradient(to right, rgba(0,0,0,0.45), transparent 55%)"
: "linear-gradient(to bottom, rgba(0,0,0,0.45), transparent 55%)",
}}
/>
</div>
{/* Side B */}
<div
style={{
position: "absolute",
top: mode !== "reveal" && !isHoriz ? "50%" : 0,
left: mode !== "reveal" && isHoriz ? "50%" : 0,
width: mode !== "reveal" && isHoriz ? "50%" : "100%",
height: mode !== "reveal" && !isHoriz ? "50%" : "100%",
overflow: "hidden",
opacity: bOp,
background: bSrc ? "#000" : bBg,
clipPath: mode === "reveal" ? revealClipPath : undefined,
WebkitClipPath: mode === "reveal" ? revealClipPath : undefined,
}}
>
{bSrc && (
<Img
src={bSrc.startsWith("http") ? bSrc : staticFile(bSrc)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${zoomB})`,
}}
/>
)}
<div
style={{
position: "absolute",
inset: 0,
background: isHoriz
? "linear-gradient(to left, rgba(0,0,0,0.45), transparent 55%)"
: "linear-gradient(to top, rgba(0,0,0,0.45), transparent 55%)",
}}
/>
</div>
{/* Divider line */}
{showDivider && (
<div
style={{
position: "absolute",
...(isHoriz
? {
top: 0,
bottom: 0,
left: mode === "reveal" ? `${revealT * 100}%` : "50%",
width: 5,
transform: "translateX(-2.5px)",
}
: {
left: 0,
right: 0,
top: mode === "reveal" ? `${revealT * 100}%` : "50%",
height: 5,
transform: "translateY(-2.5px)",
}),
background: dividerColor,
opacity: dividerOp,
boxShadow: `0 0 20px ${dividerColor}66, 0 0 40px ${dividerColor}33`,
zIndex: 5,
}}
/>
)}
{/* Title overhead */}
{title && (
<div
style={{
position: "absolute",
top: 90,
left: 0,
right: 0,
textAlign: "center",
fontFamily: FONT,
fontSize: 60,
fontWeight: 900,
color: "#fff",
letterSpacing: -1.5,
textShadow: "0 4px 20px rgba(0,0,0,0.8)",
clipPath: `inset(0 ${titleReveal}% 0 0)`,
WebkitClipPath: `inset(0 ${titleReveal}% 0 0)`,
zIndex: 10,
}}
>
{title}
</div>
)}
{/* A label — positioned as a chip at the BOTTOM of the A side */}
<div
style={{
position: "absolute",
...(isHoriz
? {
bottom: 140,
left: 40,
transform: `translateX(${aLabelX}px)`,
}
: {
top: 180,
left: 40,
right: 0,
transform: `translateY(${aLabelX}px)`,
}),
textAlign: "left",
fontFamily: FONT,
opacity: aLabelOp,
zIndex: 10,
maxWidth: isHoriz ? "46%" : "100%",
}}
>
<div
style={{
fontSize: 22,
fontWeight: 700,
letterSpacing: 4,
textTransform: "uppercase",
color: "rgba(255,255,255,0.7)",
marginBottom: 10,
}}
>
{isHoriz ? "Left" : "Top"}
</div>
<div
style={{
fontSize: 64,
fontWeight: 900,
color: "#fff",
letterSpacing: -1.5,
textShadow: "0 4px 24px rgba(0,0,0,0.95)",
lineHeight: 1.0,
}}
>
{aLabel}
</div>
</div>
{/* B label — chip at bottom/right of the B side */}
<div
style={{
position: "absolute",
...(isHoriz
? {
bottom: 140,
right: 40,
transform: `translateX(${bLabelX}px)`,
}
: {
bottom: 180,
left: 0,
right: 40,
transform: `translateY(-${bLabelX}px)`,
}),
textAlign: "right",
fontFamily: FONT,
opacity: bLabelOp,
zIndex: 10,
maxWidth: isHoriz ? "46%" : "100%",
}}
>
<div
style={{
fontSize: 22,
fontWeight: 700,
letterSpacing: 4,
textTransform: "uppercase",
color: "rgba(255,255,255,0.7)",
marginBottom: 10,
}}
>
{isHoriz ? "Right" : "Bottom"}
</div>
<div
style={{
fontSize: 64,
fontWeight: 900,
color: "#fff",
letterSpacing: -1.5,
textShadow: "0 4px 24px rgba(0,0,0,0.95)",
lineHeight: 1.0,
}}
>
{bLabel}
</div>
</div>
{/* VS badge in the middle */}
{mode === "versus" && (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${vsScale * vsPulse})`,
width: 220,
height: 220,
borderRadius: "50%",
background: "#fff",
color: "#000",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: FONT,
fontSize: 96,
fontWeight: 900,
fontStyle: "italic",
letterSpacing: -3,
boxShadow: "0 10px 50px rgba(0,0,0,0.7)",
border: "8px solid #000",
opacity: vsOp,
zIndex: 15,
}}
>
VS
</div>
)}
</AbsoluteFill>
);
};
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: ComparisonSplit
// 11 props detected · 1 port(s)
Comparison Split
comparison_splitComparison Split — Andy's hand-crafted Remotion scene. 11 props. title:TEXT.
Workflow Inputs (1)
- Title
titleTEXT
Config Fields (10)
aBgbBgaSrcbSrcmodeaLabelbLabelorientationshowDividerdividerColor
Meta
- Updated
- 4/21/2026