Split
How to build split
A disc cracks down the middle, the halves slide past each other while the whole pair turns, and it all seals back into one circle. It reads as two choreographies — rotation and slide — but a single progress number drives them both. The perfect landing is geometry's job, and this article walks through the assembly that makes it so.
- Published
- June 12, 2026
- Topics
- Shear · Rotation · Easing · SVG
Set up two half-discs and one progress number
- 2 half-discs, made by cutting a round disc along a vertical line — drawing one takes just a center position, a radius
discRadius, and the direction it bulges - the progress u — 0 is the rest before the crack, 1 is full separation, 2 is the resealed rest; its keyframes are the 3 placed in
uKeys - 2 numbers computed from u every frame — the group rotation angle (it keeps advancing in proportion to u) and the slide distance (out to
slideMaxand back) - a gap-prevention nudge
seamInset— each half sinks slightly into its partner so the S-neck never tears open
Two functions are all it takes: keyAt, which interpolates between the keyframes you placed to get the current u, and piecesAt, which turns u into the two half-disc placements.
// Only one number is animated: the progress u — 0 at rest, 1 at full separation, 2 resealed.
// Bare lowercase names (splitStart, slideMax) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not any value.
const openCurve = cubicBezier(...openHandles); // pacing of the crack-apart beat — same standard as CSS cubic-bezier()
const closeCurve = cubicBezier(...closeHandles); // pacing of the seal-back beat
// the keyframes placed on u: 0 → 1 → 2. Up to value 1 is the way out; up to 2 is the way home. Each keyframe's curve shapes the segment to the next keyframe
const uKeys = [
{ frame: splitStart, value: 0, curve: openCurve }, // a whole circle at rest until here
{ frame: apartPeak, value: 1, curve: closeCurve }, // full separation — the turn is only halfway
{ frame: sealEnd, value: 2, curve: closeCurve }, // sealed — the rest is stillness
];
// fill between placed keyframes with curves — every motion in this article has this shape
function keyAt(keys, frame) {
if (frame <= keys[0].frame) return keys[0].value; // at or before the first key — hold the first value
if (frame > keys.at(-1).frame) return keys.at(-1).value; // after the last key — hold the last value
const next = keys.findIndex((key) => frame <= key.frame); // index of the key we are heading toward
const a = keys[next - 1], b = keys[next]; // that key and the one before it bound the segment
const t = (frame - a.frame) / (b.frame - a.frame); // progress inside the segment, 0–1
return a.value + (b.value - a.value) * a.curve(t); // advance the gap along the curve
}
// frame number in, the two half-disc placements out (loops every loopFrames)
function piecesAt(frame) {
const u = keyAt(uKeys, frame % loopFrames);
const turn = (turnPerLoop * u) / 2; // rotation rides u — no easing of its own, and it never reverses
const slide = slideMax * (1 - Math.abs(1 - u)); // the slide is a hill — out, then back
const inset = Math.min(slide, seamInset); // the nudge into the partner — caps out fast
// slide along the cut direction cutAngle (90 = vertical), nudge at a right angle to it
const cut = (cutAngle * Math.PI) / 180;
const alongX = Math.cos(cut); // direction along the cut
const alongY = Math.sin(cut);
const intoX = alongY; // direction half A sinks into its partner
const intoY = -alongX;
// build half A's offset, rotate it by the group turn, then add the center
const shiftX = intoX * inset - alongX * slide;
const shiftY = intoY * inset - alongY * slide;
const g = (turn * Math.PI) / 180;
const ax = centerX + Math.cos(g) * shiftX - Math.sin(g) * shiftY;
const ay = centerY + Math.sin(g) * shiftX + Math.cos(g) * shiftY;
// bulge direction = right angle to the cut + the group turn. Half B is A's point reflection
const bulgeA = cutAngle + 90 + turn;
return [
{ x: ax, y: ay, r: discRadius, bulge: bulgeA },
{ x: 2 * centerX - ax, y: 2 * centerY - ay, r: discRadius, bulge: bulgeA + 180 },
];
}openCurve and closeCurve follow the same standard as CSS cubic-bezier(), and the 4 numbers you put in openHandles go straight into npm's bezier-easing. Outside the keyframe range, keyAt returns the end values, so the stillness before the crack and after the seal costs nothing.
The heart is the first 3 lines of piecesAt. Rotation rides u with no easing of its own; the slide rides a hill (1 - Math.abs(1 - u)) — out, then back. Because both read the same u, the group is exactly halfway through its turn at the instant of full separation, when u reads exactly 1. The work of synchronizing two motions simply doesn't exist in the first place.
One drawing covers both halves. The arc's two endpoints sit a radius away from the piece's center, one to each side, at right angles to the bulge direction — the flat edge joining them is the cut. Connect the 2 points with a half-circle arc that bows out on the bulge side; in SVG that is a path with a single arc command, with the sweep chosen so the arc bows toward the bulge.
For the bulge direction, use the value piecesAt returns each frame — the group turn is already folded into it. Half B is half A's point reflection (exactly opposite across the center), so the last line of piecesAt flips the coordinates about the center and adds a half turn to the bulge.
The nudge line quietly earns its keep. Mid-slide, each half sinks seamInset into its partner, so the S-neck never opens a hair-thin gap. Math.min caps it, and at rest it reads zero — the circle's shape stays untouched.
Drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second to get frame, take the remainder against loopFrames, and it loops. Here the single fill does the closing work. The loop ends with the pair turned a right angle — the cut now horizontal instead of vertical — but a circle painted in one color carries no orientation, so the last frame is indistinguishable from the first and the seam disappears.
Flip that around: paint the halves in 2 colors and the loop hard-cuts at the seam as the color layout jumps — the single fill is a structural requirement, not a styling choice. The shape need not be a circle either. In the research repo's generality demo, a square cut by a horizontal line and making a half turn ran on the same piecesAt untouched. Any shape whose turned pose is indistinguishable from where it started will do.