Catalog

Edit Props (live)

TEXT
config
config
config
config
config
config
config
config
config
config

Source

369 lines
import 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_split

Comparison Split — Andy's hand-crafted Remotion scene. 11 props. title:TEXT.

Workflow Inputs (1)

  • Title
    title
    TEXT

Config Fields (10)

  • aBg
  • bBg
  • aSrc
  • bSrc
  • mode
  • aLabel
  • bLabel
  • orientation
  • showDivider
  • dividerColor

Meta

Updated
4/21/2026