Wireflow is now a Claude connector.

Set it up
Catalog

Edit Props (live)

IMAGE
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
config
config

Source

712 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", "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_head

Split Head — Andy's hand-crafted Remotion scene. 16 props. topLogo:IMAGE, captionText:TEXT, captionStyle:TEXT.

Workflow Inputs (3)

  • Top Logo
    topLogo
    IMAGE
  • Caption Text
    captionText
    TEXT
  • Caption Style
    captionStyle
    TEXT

Config Fields (13)

  • y
  • topSrc
  • headSrc
  • topMode
  • topTilt
  • topWordmark
  • cursorTarget
  • topScrollAmt
  • headStartFrame
  • topAccentColor
  • backgroundColor
  • topWordmarkColor
  • topBackgroundColor

Meta

Updated
4/21/2026