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 →