Skip to content

BeforeAfter

Two versions of one picture split by a moving divider with a grip and labels: a clip with and without an effect, a redesign, a cleanup.

Loading
00:00:00

Loading the live preview

Props
theme
set in code
set in code

Divider position 0–1, or keys [frame, value, ease?]. Default: a sweep.

Usage, as set above

Install

Copies the source into your project. It's yours from then on: edit it like any other file.

npx framefields add before-after

Writes one file (before-after/index.ts) into your components folder. Then:

import { BeforeAfter } from "./components/before-after/index.js";
BeforeAfter({ before: "assets/raw.mp4", after: graded })

Props

Plus the props every component takes: at, duration, hold,enter, exit, x, y, width, height,theme, seed and customize.

PropTypeDefault
beforerequiredunknown
afterrequiredunknown
splitnumber | Array<[number, number] | [number, number, string]>
Divider position 0–1, or keys [frame, value, ease?]. Default: a sweep.
direction"horizontal" | "vertical""horizontal"
labels[string, string] | false["Before","After"]
handlebooleantrue
fit"cover" | "contain" | "fill""cover"
colorstring"#ffffff"

Cues

Frames other layers can key off, on the returned node: node.cues.settled[0].frame. Change the props and everything keyed to a cue moves with it.

settled

Parts

The layers customize receives by name, to restyle or animate one piece without forking the component.

afterbeforebefore-clipdividerhandlelabel-0label-1

Source

What framefields add before-after writes for before-after/index.ts.

Show 326 lines
before-after/index.ts
/**
* BeforeAfter: two versions of one picture split by a moving divider. The
* `before` side is clipped to the divider; both sides are laid out at the
* full size, so they line up pixel for pixel. Give one clip twice, once
* with an effect, to show what the effect does.
*/
import {
type KeyframeTuple,
LayerAnimation,
type LayoutNode,
} from "framefields";
import {
type Animatable,
bakeSamples,
bindComponent,
defineComponent,
round,
scaleFrames,
valueAt,
z,
} from "framefields/components";
export interface BeforeAfterCues {
[name: string]: ReadonlyArray<{ frame: number }>;
/** The divider's last move ends. */
settled: ReadonlyArray<{ frame: number }>;
}
const side = () =>
z.custom<LayoutNode | LayoutNode[] | string>(
(v) =>
typeof v === "string" ||
Array.isArray(v) ||
(typeof v === "object" && v !== null),
{ message: "Expected layers or a media path for this side" },
);
export const beforeAfter = /* @__PURE__ */ defineComponent({
name: "BeforeAfter",
props: () => ({
before: side(),
after: side(),
/** Divider position 0–1, or keys `[frame, value, ease?]`. Default: a sweep. */
split: z
.union([
z.number().min(0).max(1),
z
.array(
z.union([
z.tuple([z.number().int().min(0), z.number().min(0).max(1)]),
z.tuple([
z.number().int().min(0),
z.number().min(0).max(1),
z.string(),
]),
]),
)
.min(1),
])
.optional(),
direction: z.enum(["horizontal", "vertical"]).default("horizontal"),
labels: z
.union([z.tuple([z.string(), z.string()]), z.literal(false)])
.default(["Before", "After"]),
handle: z.boolean().default(true),
fit: z.enum(["cover", "contain", "fill"]).default("cover"),
color: z.string().default("#ffffff"),
}),
defaults: { enter: false, exit: false },
build(ctx, props) {
const { host, theme, fps } = ctx;
const s = theme.scale;
const W = ctx.requested.width ?? 1280 * s;
const H = ctx.requested.height ?? 720 * s;
const across = props.direction === "horizontal";
const L = across ? W : H;
const f = (n: number) => scaleFrames(n, fps);
const keys: KeyframeTuple[] =
typeof props.split === "number"
? [[0, props.split]]
: ((props.split as KeyframeTuple[] | undefined) ?? [
[0, 0.5],
[f(20), 0.5],
[f(50), 0.15, "power2.inOut"],
[f(100), 0.85, "power2.inOut"],
[f(135), 0.5, "power2.inOut"],
]);
const sorted = [...keys].sort(
(a, b) => (a[0] as number) - (b[0] as number),
);
const px = (k: KeyframeTuple[], by = 0): KeyframeTuple[] =>
k.map(([fr, v, e]) =>
e
? [fr, round((v as number) * L + by), e]
: [fr, round((v as number) * L + by)],
) as KeyframeTuple[];
const settled = sorted[sorted.length - 1]?.[0] as number;
const fill = (name: string, content: LayoutNode | LayoutNode[] | string) =>
typeof content === "string"
? [
host.media(content, {
id: ctx.partId(`${name}-media`),
position: "absolute",
x: 0,
y: 0,
width: W,
height: H,
fit: props.fit,
}),
]
: Array.isArray(content)
? content
: [content];
const parts: Record<string, Animatable<LayoutNode>> = {};
const add = <T extends Animatable<LayoutNode>>(name: string, node: T) => {
parts[name] = node;
return node;
};
const after = add(
"after",
host.box({
id: ctx.partId("after"),
position: "absolute",
x: 0,
y: 0,
width: W,
height: H,
overflow: "hidden",
children: fill("after", props.after),
}),
);
const start = (sorted[0]?.[1] as number) * L;
const clip = add(
"before-clip",
host.box({
id: ctx.partId("before-clip"),
position: "absolute",
x: 0,
y: 0,
width: across ? start : W,
height: across ? H : start,
overflow: "hidden",
children: [
add(
"before",
host.box({
id: ctx.partId("before"),
position: "absolute",
x: 0,
y: 0,
width: W,
height: H,
children: fill("before", props.before),
}),
),
],
}),
);
if (sorted.length > 1)
clip.animate(
LayerAnimation.keys(across ? "width" : "height", px(sorted)),
);
const children: LayoutNode[] = [after, clip];
// ── Divider and handle ───────────────────────────────────────────
const thick = Math.max(2, 3 * s);
const line = add(
"divider",
host.box({
id: ctx.partId("divider"),
position: "absolute",
x: across ? start - thick / 2 : 0,
y: across ? 0 : start - thick / 2,
width: across ? thick : W,
height: across ? H : thick,
background: props.color,
}),
);
host.style(line, {
dropShadow: {
color: "#000000",
opacity: 0.35,
angle: 90,
distance: 0,
size: 12 * s,
},
});
if (sorted.length > 1)
line.animate(
LayerAnimation.keys(across ? "x" : "y", px(sorted, -thick / 2)),
);
children.push(line);
if (props.handle) {
const d = 52 * s;
const c = d / 2;
const a = d * 0.12;
const chevrons = across
? `M${c - a * 1.2} ${c - a}L${c - a * 2.2} ${c}L${c - a * 1.2} ${c + a}M${c + a * 1.2} ${c - a}L${c + a * 2.2} ${c}L${c + a * 1.2} ${c + a}`
: `M${c - a} ${c - a * 1.2}L${c} ${c - a * 2.2}L${c + a} ${c - a * 1.2}M${c - a} ${c + a * 1.2}L${c} ${c + a * 2.2}L${c + a} ${c + a * 1.2}`;
const grip = add(
"handle",
host.box({
id: ctx.partId("handle"),
position: "absolute",
x: across ? start - c : W / 2 - c,
y: across ? H / 2 - c : start - c,
width: d,
height: d,
background: props.color,
borderRadius: c,
children: [
host.shape("path", {
id: ctx.partId("handle-arrows"),
position: "absolute",
x: 0,
y: 0,
width: d,
height: d,
d: chevrons,
fillType: "none",
strokeColor: "#111111",
strokeWidth: Math.max(1.5, 2.4 * s),
strokeLineCap: "round",
strokeLineJoin: "round",
}),
],
}),
);
host.style(grip, {
dropShadow: {
color: "#000000",
opacity: 0.4,
angle: 90,
distance: 3 * s,
size: 14 * s,
},
});
if (sorted.length > 1)
grip.animate(LayerAnimation.keys(across ? "x" : "y", px(sorted, -c)));
children.push(grip);
}
// ── Labels: each fades as the divider covers it ─────────────────
if (props.labels) {
const fs = 22 * s;
const pad = 14 * s;
const margin = 24 * s;
const length = Math.max(1, settled);
const splitAt = (fr: number) => valueAt(sorted, fr) * L;
props.labels.forEach((text, i) => {
const m = host.measure(text, {
fontFamily: theme.font.body,
fontSize: fs,
fontWeight: theme.weight.semibold,
});
const w = m.width + pad * 2;
const h = m.height + pad * 0.9;
const x = across ? (i === 0 ? margin : W - margin - w) : margin;
const y = across ? margin : i === 0 ? margin : H - margin - h;
// How far the label's far edge is inside its own side.
const visible = (fr: number) => {
const at = splitAt(fr);
const edge = across ? (i === 0 ? x + w : x) : i === 0 ? y + h : y;
const inside = i === 0 ? at - edge : edge - at;
return Math.max(0, Math.min(1, inside / (24 * s) + 1));
};
const pill = add(
`label-${i}`,
host.box({
id: ctx.partId(`label-${i}`),
position: "absolute",
x,
y,
width: w,
height: h,
background: "rgba(0,0,0,0.55)",
borderRadius: h / 2,
opacity: visible(0),
children: [
host.text(text, {
id: ctx.partId(`label-${i}-text`),
position: "absolute",
x: pad,
y: pad * 0.45,
fontFamily: theme.font.body,
fontSize: fs,
fontWeight: theme.weight.semibold,
fill: "#ffffff",
}),
],
}),
);
if (sorted.length > 1)
pill.animate(
LayerAnimation.keys(
"opacity",
bakeSamples(
Array.from({ length: length + 1 }, (_, fr) => visible(fr)),
0,
{ tolerance: 0.02 },
),
),
);
children.push(pill);
});
}
return {
children,
width: W,
height: H,
duration: null,
cues: { settled: [{ frame: settled }] } as BeforeAfterCues,
parts,
};
},
});
/**
* Two versions of one picture split by a moving divider with a grip and labels:
* a clip with and without an effect, a redesign, a cleanup.
*/
export const BeforeAfter = /* @__PURE__ */ bindComponent(beforeAfter);