reelcn
GitHubBack to landing

Split screen

Two to four full-bleed panes that wipe in one after another, each laid out as if it were its own canvas.

SplitScreen, duo
SplitScreen, duo thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/split-screen.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion.

Usage

<SplitScreen labels={["Before", "After"]}>
  <Img src={staticFile("before.png")} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
  <Img src={staticFile("after.png")} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</SplitScreen>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
children*ReactNodeTwo to four panes. Extra children are ignored.
labelsstring[]One label per pane, shown in its top-left corner. Default [].
layout"auto" | "grid" | "row" | "column"auto puts two or three panes side by side (stacked in portrait) and four in a 2×2 grid. Default "auto".
stepnumberFrames between one pane's wipe and the next. Defaults to 0.12s.
backgroundstringPane fill behind the children. Defaults to the theme surface.
dividerColorstringHairline between panes. Defaults to the theme border.
labelColorstringLabel text. Defaults to the theme foreground.
labelBackgroundstringLabel fill. Defaults to the theme background at 70%.
styleCSSProperties
classNamestring

Built from

Use

  • Before and after, side-by-side comparisons, reaction layouts and multi-angle shots
  • Showing the same component at two sizes, since each pane has its own viewport

Avoid

  • Arranging several small cards on one canvas, use bento-grid

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/split-screen.tsx
/**
 * @title Split Screen
 * @category motion
 * @description Two to four full-bleed panes that wipe in one after another, each laid out as if it were its own canvas.
 * @duration 30
 * @use Before and after, side-by-side comparisons, reaction layouts and multi-angle shots
 * @use Showing the same component at two sizes, since each pane has its own viewport
 * @avoid Arranging several small cards on one canvas, use `bento-grid`
 * @tags split, compare, panes, side by side, grid
 * @example
 * <SplitScreen labels={["Before", "After"]}>
 *   <Img src={staticFile("before.png")} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
 *   <Img src={staticFile("after.png")} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
 * </SplitScreen>
 */
import type React from "react";
import { Children } from "react";
import { AbsoluteFill } from "remotion";
import { alpha, type MotionProps, tween, useMotion, useTheme, useViewport, Viewport } from "./core";

export type SplitScreenProps = MotionProps & {
  /** Two to four panes. Extra children are ignored. */
  children: React.ReactNode;
  /** One label per pane, shown in its top-left corner. */
  labels?: string[];
  /** `auto` puts two or three panes side by side (stacked in portrait) and four in a 2×2 grid. */
  layout?: "auto" | "row" | "column" | "grid";
  /** Frames between one pane's wipe and the next. Defaults to 0.12s. */
  step?: number;
  /** Pane fill behind the children. Defaults to the theme surface. */
  background?: string;
  /** Hairline between panes. Defaults to the theme border. */
  dividerColor?: string;
  /** Label text. Defaults to the theme foreground. */
  labelColor?: string;
  /** Label fill. Defaults to the theme background at 70%. */
  labelBackground?: string;
  style?: React.CSSProperties;
  className?: string;
};

type Rect = { left: number; top: number; width: number; height: number; row: number; column: number };

/** Pixel rectangles for each pane; a short last row stretches to fill the width. */
function paneRects(count: number, columns: number, width: number, height: number): Rect[] {
  const rows = Math.ceil(count / columns);
  const rects: Rect[] = [];
  for (let index = 0; index < count; index++) {
    const row = Math.floor(index / columns);
    const column = index % columns;
    const inRow = Math.min(columns, count - row * columns);
    const paneWidth = width / inRow;
    rects.push({
      left: column * paneWidth,
      top: (row * height) / rows,
      width: paneWidth,
      height: height / rows,
      row,
      column,
    });
  }
  return rects;
}

export function SplitScreen({
  children,
  labels = [],
  layout = "auto",
  step,
  background,
  dividerColor,
  labelColor,
  labelBackground,
  style,
  className,
  ...motion
}: SplitScreenProps) {
  const theme = useTheme();
  const { width, height, u, safe, isPortrait } = useViewport();
  const m = useMotion(motion);
  const panes = Children.toArray(children).slice(0, 4);
  const shape = layout === "auto" ? (panes.length === 4 ? "grid" : isPortrait ? "column" : "row") : layout;
  const columns = shape === "grid" ? 2 : shape === "row" ? panes.length : 1;
  const rects = paneRects(panes.length, columns, width, height);
  const frames = step ?? Math.round(m.fps * 0.12);
  // Side-by-side panes wipe downward; stacked panes wipe across.
  const vertical = columns > 1;

  return (
    <AbsoluteFill className={className} style={{ fontFamily: theme.fonts.body, ...style }}>
      {panes.map((pane, index) => {
        const rect = rects[index];
        const p = tween(m.frame, m.fps, { from: m.delay + index * frames, duration: m.enterFrames, motion: m.preset });
        const shown = Math.min(Math.max(p, 0), 1);
        const labelIn = tween(m.frame, m.fps, {
          from: m.delay + index * frames + m.enterFrames * 0.4,
          duration: m.enterFrames,
          motion: m.preset,
        });
        const enterEdge = `${(1 - shown) * 100}%`;
        const exitEdge = `${m.exit * 100}%`;
        return (
          <div
            key={index}
            style={{
              position: "absolute",
              left: rect.left,
              top: rect.top,
              width: rect.width,
              height: rect.height,
              overflow: "hidden",
              background: background ?? theme.colors.surface,
              // Enter wipes in from the leading edge; exit carries the same wipe on through the far edge.
              clipPath: vertical ? `inset(${exitEdge} 0 ${enterEdge} 0)` : `inset(0 ${enterEdge} 0 ${exitEdge})`,
            }}
          >
            <div
              style={{
                position: "absolute",
                inset: 0,
                scale: String(1 + 0.08 * (1 - shown)),
              }}
            >
              <Viewport width={rect.width} height={rect.height}>
                {pane}
              </Viewport>
            </div>
            {rect.column > 0 && (
              <div
                style={{
                  position: "absolute",
                  left: 0,
                  top: 0,
                  bottom: 0,
                  width: 1,
                  background: dividerColor ?? theme.colors.border,
                }}
              />
            )}
            {rect.row > 0 && (
              <div
                style={{
                  position: "absolute",
                  left: 0,
                  right: 0,
                  top: 0,
                  height: 1,
                  background: dividerColor ?? theme.colors.border,
                }}
              />
            )}
            {labels[index] && (
              <div
                style={{
                  position: "absolute",
                  left: u(40),
                  top: isPortrait && rect.row === 0 ? safe.top : u(40),
                  padding: `${u(10)}px ${u(22)}px`,
                  borderRadius: u(theme.radius),
                  border: `1px solid ${dividerColor ?? theme.colors.border}`,
                  background: labelBackground ?? alpha(theme.colors.background, 0.7),
                  color: labelColor ?? theme.colors.foreground,
                  fontSize: u(28),
                  fontWeight: 600,
                  letterSpacing: "0.01em",
                  opacity: Math.min(Math.max(labelIn, 0), 1),
                  translate: `0 ${(1 - labelIn) * u(16)}px`,
                }}
              >
                {labels[index]}
              </div>
            )}
          </div>
        );
      })}
    </AbsoluteFill>
  );
}

On this page