Split Scene

Combine multiple compositions in one frame using a preset layout — stacked, side-by-side, picture-in-picture, or 2×2 grid.

Preview

Open editor

Usage

Pick a layout, then assign one composition per slot. The number of slots changes with the layout — stacked and side-by-side give you 2, pip gives you 2 (background + corner inset), grid-2x2 gives you 4.

Each slot renders the chosen composition with cover fit so it fills the slot edge-to-edge. No coordinates to type — for the 90% of "X next to Y" combinations this is everything you need.

Props

NameTypeDefault
layout"side-by-side" | "stacked" | "pip" | "grid-2x2""side-by-side"
slotsRecord<string, string[]>[2 items]
gapnumber16

Composition

ID
SplitScene
Resolution
1920×1080
FPS
60
Duration
10.0s

Source

Copy or download the React source — drop it into your own Remotion project. The only runtime dependency is remotion.

"use client";
import { AbsoluteFill } from "remotion";
import { type ClipStyle, resolveClipStyle } from "../../clip-style";
import { componentsByIdBase as componentsById } from "../../componentsBase";
import { compositionsById } from "../../registry";
import type { SplitLayout } from "./layout";

export type { SplitLayout } from "./layout";

export type SplitSceneProps = {
  layout: SplitLayout;
  slots: string[];
  gap: number;
  clipStyle?: ClipStyle;
};

const CANVAS_W = 1920;
const CANVAS_H = 1080;

type Rect = {
  x: number;
  y: number;
  w: number;
  h: number;
  radius?: number;
  shadow?: boolean;
};

function getSlotRects(layout: SplitLayout, gap: number): Rect[] {
  const g = Math.max(0, gap);

  switch (layout) {
    case "stacked": {
      const h = (CANVAS_H - g) / 2;
      return [
        { x: 0, y: 0, w: CANVAS_W, h },
        { x: 0, y: h + g, w: CANVAS_W, h },
      ];
    }
    case "side-by-side": {
      const w = (CANVAS_W - g) / 2;
      return [
        { x: 0, y: 0, w, h: CANVAS_H },
        { x: w + g, y: 0, w, h: CANVAS_H },
      ];
    }
    case "pip": {
      const insetW = CANVAS_W * 0.32;
      const insetH = CANVAS_H * 0.32;
      const margin = 56;
      return [
        { x: 0, y: 0, w: CANVAS_W, h: CANVAS_H },
        {
          x: CANVAS_W - insetW - margin,
          y: CANVAS_H - insetH - margin,
          w: insetW,
          h: insetH,
          radius: 28,
          shadow: true,
        },
      ];
    }
    case "grid-2x2": {
      const w = (CANVAS_W - g) / 2;
      const h = (CANVAS_H - g) / 2;
      return [
        { x: 0, y: 0, w, h },
        { x: w + g, y: 0, w, h },
        { x: 0, y: h + g, w, h },
        { x: w + g, y: h + g, w, h },
      ];
    }
  }
}

export const SplitScene: React.FC<SplitSceneProps> = ({
  layout,
  slots,
  gap,
  clipStyle,
}) => {
  const s = resolveClipStyle(clipStyle, {
    background: "#0f1014",
    color: "#ffffff",
    fontFamily:
      "-apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif",
    accent: "#6366f1",
  });
  const rects = getSlotRects(layout, gap);

  return (
    <AbsoluteFill style={{ background: s.background, overflow: "hidden" }}>
      {rects.map((rect, i) => {
        const compId = slots[i];
        return <Slot key={i} rect={rect} compositionId={compId ?? ""} />;
      })}
    </AbsoluteFill>
  );
};

function Slot({ rect, compositionId }: { rect: Rect; compositionId: string }) {
  const Component = componentsById[compositionId];
  const info = compositionsById[compositionId];
  const radius = rect.radius ?? 0;
  const shadow = rect.shadow
    ? "0 30px 80px rgba(0,0,0,0.45), 0 0 0 2px rgba(255,255,255,0.08)"
    : undefined;

  return (
    <div
      style={{
        position: "absolute",
        left: rect.x,
        top: rect.y,
        width: rect.w,
        height: rect.h,
        background: "#000",
        borderRadius: radius,
        overflow: "hidden",
        boxShadow: shadow,
      }}
    >
      {Component && info ? (
        <ScaledScene
          Component={Component}
          compW={info.width}
          compH={info.height}
          slotW={rect.w}
          slotH={rect.h}
          defaultProps={info.defaultProps}
        />
      ) : (
        <EmptySlot />
      )}
    </div>
  );
}

function ScaledScene({
  Component,
  compW,
  compH,
  slotW,
  slotH,
  defaultProps,
}: {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  Component: React.ComponentType<any>;
  compW: number;
  compH: number;
  slotW: number;
  slotH: number;
  defaultProps: Record<string, unknown>;
}) {
  const fit = Math.max(slotW / compW, slotH / compH);
  const renderedW = compW * fit;
  const renderedH = compH * fit;
  const offsetX = (slotW - renderedW) / 2;
  const offsetY = (slotH - renderedH) / 2;

  return (
    <div
      style={{
        position: "absolute",
        left: offsetX,
        top: offsetY,
        width: compW,
        height: compH,
        transform: `scale(${fit})`,
        transformOrigin: "top left",
      }}
    >
      <Component {...defaultProps} />
    </div>
  );
}

function EmptySlot() {
  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color: "rgba(255,255,255,0.35)",
        fontFamily:
          "-apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif",
        fontSize: 22,
      }}
    >
      Empty slot
    </div>
  );
}
Save as SplitScene/SplitScene.tsx