Cutship
Sign inGet started
← All edits
cutship.ai

Component · Transitions · 9:16

Liquid Mask Transition

by CutshipVerified

A glossy wave of color floods up the frame with droplets breaking off its crest, and the next clip pours in right behind it through the same rippling liquid edge.

0 taken · 1 views

Included with Pro

Sign up free to Make this

Sign up free to unlock the spec, agent prompt and Make this.

components/remotion/liquid-mask-transition/component.tsx

import React from "react";
import { Video } from "@remotion/media";
import {
  AbsoluteFill,
  Easing,
  Sequence,
  interpolate,
  random,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import type { LiquidMaskTransitionProps } from "./schema";

const resolveSrc = (src: string) => (/^(https?:|data:|blob:|\/)/.test(src) ? src : staticFile(src));

const Clip: React.FC<{ src: string; node?: React.ReactNode }> = ({ src, node }) =>
  node ? <>{node}</> : src ? <Video src={resolveSrc(src)} objectFit="cover" style={{ width: "100%", height: "100%" }} /> : null;

type Pt = [number, number];
type RGB = [number, number, number];

const hexToRgb = (hex: string): RGB | null => {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return null;
  const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
};
const mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map((i) => Math.round(a[i] + (b[i] - a[i]) * t)) as RGB;
const rgb = ([r, g, b]: RGB, a = 1) => `rgba(${r},${g},${b},${a})`;

const signedArea = (pts: Pt[]) => {
  let s = 0;
  for (let i = 0; i < pts.length; i++) {
    const [x1, y1] = pts[i];
    const [x2, y2] = pts[(i + 1) % pts.length];
    s += x1 * y2 - x2 * y1;
  }
  return s;
};
const toPath = (pts: Pt[], close = true) =>
  pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ") + (close ? " Z" : "");

type Blob = { u: number; v: number; r: number };

/** One liquid layer: a rippling surface plus the droplets riding ahead of it, as polygons in screen space. */
const liquidShape = (opts: {
  level: number; // distance the surface has travelled, px along the flow axis
  amp: number; // wave amplitude, px
  waviness: number;
  frame: number;
  phase: number;
  seed: string;
  droplets: number;
  progress: number;
  cross: number; // frame size across the flow, px
  map: (u: number, v: number) => Pt;
}) => {
  const { level, amp, waviness, frame, phase, seed, droplets, progress, cross, map } = opts;
  const waves = [
    { f: waviness, a: 1, w: 0.16 },
    { f: waviness * 1.93, a: 0.38, w: -0.23 },
    { f: waviness * 3.4, a: 0.08, w: 0.37 },
  ].map((k, i) => ({ ...k, p: random(`${seed}-p${i}`) * Math.PI * 2 + phase }));
  const norm = waves.reduce((s, k) => s + k.a, 0);
  const surface = (u: number) =>
    level + (amp / norm) * waves.reduce((s, k) => s + k.a * Math.sin(u * k.f * Math.PI * 2 + k.p + frame * k.w), 0);

  const N = 72;
  const edge: Pt[] = [];
  for (let i = 0; i <= N; i++) {
    const u = -0.02 + (1.04 * i) / N;
    edge.push(map(u, surface(u)));
  }
  const pad = cross; // well past the origin side
  const body: Pt[] = [map(-0.02, -pad), ...edge, map(1.02, -pad)];
  const sign = Math.sign(signedArea(body)) || 1;

  // Droplets pop out of the crest, hang ahead of it, and get swallowed again.
  const blobs: Blob[] = [];
  for (let i = 0; i < droplets; i++) {
    const s = 0.08 + random(`${seed}-s${i}`) * 0.5;
    const q = (progress - s) / 0.38;
    if (q <= 0 || q >= 1) continue;
    const u = 0.06 + random(`${seed}-u${i}`) * 0.88;
    const base = cross * (0.016 + random(`${seed}-r${i}`) * 0.03);
    const life = Math.sin(Math.PI * q);
    const r = base * Math.pow(life, 0.55);
    const gap = base * (0.1 + 2.6 * life) + r * 0.2;
    blobs.push({ u, v: surface(u) + gap, r });
  }
  const circles = blobs.map(({ u, v, r }) => {
    const [cx, cy] = map(u, v);
    const pts: Pt[] = [];
    for (let i = 0; i < 28; i++) {
      const a = (i / 28) * Math.PI * 2;
      pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
    }
    // Same winding as the body so the union never punches holes (nonzero fill).
    return Math.sign(signedArea(pts)) === sign ? pts : pts.reverse();
  });
  // A second, inset copy of the surface for the inner gloss line.
  const inner: Pt[] = edge.map((_, i) => {
    const u = -0.02 + (1.04 * i) / N;
    return map(u, surface(u) - amp * 0.35 - cross * 0.012);
  });

  return {
    clip: [toPath(body), ...circles.map((c) => toPath(c))].join(" "),
    rimPath: [toPath(edge, false), ...circles.map((c) => toPath(c))].join(" "),
    innerPath: toPath(inner, false),
  };
};

/** Clip B pours in behind a glossy wave of color that floods across clip A, droplets breaking off its crest. */
export const LiquidMaskTransition: React.FC<
  LiquidMaskTransitionProps & { clipA?: React.ReactNode; clipB?: React.ReactNode }
> = ({
  srcA,
  srcB,
  transitionFrame,
  durationInFrames: D,
  direction,
  liquidColor,
  showLiquidLayer,
  waveHeight,
  waviness,
  droplets,
  rim,
  seed,
  brandKit,
  clipA,
  clipB,
}) => {
  const frame = useCurrentFrame();
  const { width: W, height: H } = useVideoConfig();

  const vertical = direction === "up" || direction === "down";
  const along = vertical ? H : W;
  const cross = vertical ? W : H;
  const map = (u: number, v: number): Pt =>
    direction === "up"
      ? [u * W, H - v]
      : direction === "down"
        ? [u * W, v]
        : direction === "right"
          ? [v, u * H]
          : [W - v, u * H];

  const base = hexToRgb(brandKit?.colors?.[0] ?? "") ?? hexToRgb(liquidColor) ?? [123, 92, 255];
  const light = mix(base, [255, 255, 255], 0.38);
  const deep = mix(base, [0, 0, 0], 0.28);

  const start = transitionFrame - D / 2;
  const end = start + D;
  const lag = showLiquidLayer ? D * 0.18 : 0;
  const A = waveHeight * along;
  const margin = A * 1.2 + cross * 0.16; // room for waves and droplets on both ends

  const layer = (from: number, to: number, key: string, phase: number, drops: number) => {
    const p = interpolate(frame, [from, to], [0, 1], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.bezier(0.42, 0, 0.32, 1),
    });
    const amp = A * (0.35 + 0.65 * Math.sin(Math.PI * p));
    const level = interpolate(p, [0, 1], [-margin, along + margin]);
    return {
      p,
      ...liquidShape({ level, amp, waviness, frame, phase, seed: `${seed}-${key}`, droplets: drops, progress: p, cross, map }),
    };
  };

  const lead = showLiquidLayer ? layer(start, end - lag, "lead", 0, droplets) : null;
  const back = layer(start + lag, end, "clip", 1.3, showLiquidLayer ? Math.ceil(droplets / 3) : droplets);

  const stroke = cross * 0.007;
  const gradAngle = { up: 0, down: 180, right: 90, left: 270 }[direction];
  const active = frame >= start - 1 && frame <= end + 1;

  return (
    <AbsoluteFill style={{ backgroundColor: "#000", overflow: "hidden" }}>
      {back.p < 1 ? (
        <AbsoluteFill>
          <Clip src={srcA} node={clipA} />
        </AbsoluteFill>
      ) : null}

      {lead && lead.p > 0 && back.p < 1 ? (
        <>
          {/* Soft shadow the wave casts on clip A, so it reads as a layer of liquid on top. */}
          <svg width={W} height={H} style={{ position: "absolute", inset: 0, filter: `blur(${cross * 0.02}px)` }}>
            <path d={lead.clip} fill="rgba(0,0,0,0.35)" transform={`translate(${vertical ? 0 : (direction === "right" ? 1 : -1) * cross * 0.02} ${vertical ? (direction === "up" ? -1 : 1) * cross * 0.02 : 0})`} />
          </svg>
          <AbsoluteFill
            style={{
              clipPath: `path('${lead.clip}')`,
              background: `linear-gradient(${gradAngle}deg, ${rgb(deep)} 0%, ${rgb(base)} 55%, ${rgb(light)} 100%)`,
            }}
          />
          <svg width={W} height={H} style={{ position: "absolute", inset: 0 }}>
            <path d={lead.innerPath} fill="none" stroke={rgb([255, 255, 255], 0.22 * rim)} strokeWidth={stroke * 2.4} strokeLinecap="round" />
            <path d={lead.rimPath} fill="none" stroke={rgb(light, 0.95 * rim)} strokeWidth={stroke} strokeLinejoin="round" />
          </svg>
        </>
      ) : null}

      <Sequence from={Math.floor(start)} layout="none">
        <AbsoluteFill style={{ clipPath: back.p >= 1 ? undefined : `path('${back.clip}')` }}>
          <Clip src={srcB} node={clipB} />
        </AbsoluteFill>
      </Sequence>

      {active && back.p > 0 && back.p < 1 ? (
        <svg width={W} height={H} style={{ position: "absolute", inset: 0, filter: `drop-shadow(0 0 ${stroke * 2}px ${rgb(light, 0.8 * rim)})` }}>
          <path d={back.rimPath} fill="none" stroke={rgb([255, 255, 255], 0.85 * rim)} strokeWidth={stroke * 0.8} strokeLinejoin="round" />
        </svg>
      ) : null}
    </AbsoluteFill>
  );
};

export default LiquidMaskTransition;

Remotion code you can paste anywhere, or install it: npx cutship add liquid-mask-transition

MIT License: use it in any video, personal or commercial. License

More from Cutship

Creator page →