Every time I write a CSS
@keyframesrule I end up cycling through-40→-30→-50→ reload → "still not right" →-45→ … forever. I built a 500-line vanilla JS tool with a timeline UI for editing stops and properties live, with the generated CSS ready to copy. While building it I hit a famously confusing CSS quirk: re-applying the sameanimationdeclaration doesn't restart the animation. Here's how the tool works and how to defeat that trap.
🌐 Demo: https://sen.ltd/portfolio/css-animation-designer/
📦 GitHub: https://github.com/sen-ltd/css-animation-designer
Design: generator ⟂ UI
keyframes.js ← @keyframes / animation shorthand string generation (no DOM)
presets.js ← 6 ready-made configs (bounce / fade / spin / pulse / shake / slide)
app.js ← UI glue: state edits → keyframes.js → <style> tag injection
keyframes.js has neither document nor <style>. It takes a config object and returns a CSS string:
export function buildKeyframes(name, frames) {
if (!frames || frames.length === 0) return `@keyframes ${name} {}`;
// Sort by stop so the output is deterministic regardless of input order.
const sorted = [...frames].sort((a, b) => a.stop - b.stop);
const lines = sorted.map((f) => {
const decls = Object.entries(f.props)
.filter(([, v]) => v !== "" && v != null)
.map(([k, v]) => `${k}: ${v};`)
.join(" ");
return ` ${f.stop}% { ${decls} }`;
});
return `@keyframes ${name} {\n${lines.join("\n")}\n}`;
}
Sorting inside the generator means the UI doesn't need to. If the user adds a 75% stop after 100%, the array contains [0, 100, 75] but the output stays cleanly ordered.
The shorthand:
export function buildAnimationDecl(animation) {
const parts = [animation.name, `${animation.duration}s`, animation.easing || "ease"];
if (animation.iterations !== undefined && animation.iterations !== 1) {
parts.push(animation.iterations === "infinite" ? "infinite" : String(animation.iterations));
}
if (animation.fillMode && animation.fillMode !== "none") {
parts.push(animation.fillMode);
}
return parts.join(" ");
}
Defaults are omitted (iterations: 1, fillMode: "none"). The resulting animation: bounce 1s ease infinite; looks like what you'd write by hand — not bounce 1s ease 1 none normal running cluttered with all the implicit values.
Live preview: inject a <style> tag
The naive approach is element.style.animationName = .... It works, but:
- DevTools' "Animations" inspector doesn't see the
@keyframesrule - You have to keep multiple style props in sync manually
Instead, maintain one <style> element and rewrite its textContent:
const styleTag = document.createElement("style");
styleTag.id = "live-animation";
document.head.appendChild(styleTag);
function update() {
const css = buildFullCSS(state, "#preview");
styleTag.textContent = css; // replaces both @keyframes and #preview { animation: ... }
}
The browser's CSS parser treats this as a normal stylesheet rule, so DevTools picks it up and you can scrub the animation timeline in the Animations panel. Free debugging.
The "same animation won't restart" trap
This is the one that bites everyone who ships an "animate on click" feature for the first time.
If you call update() while the preview is already running the same animation, the browser does nothing. Per the CSS Animations Level 1 spec, when the computed value of animation-* properties doesn't change, no animation restart happens. The browser sees animation: bounce 1s ease infinite; is identical to what's already applied and optimises the no-op away.
To force a restart you need to make the animation declaration different for a moment, and you have to force a reflow so the browser actually processes the intermediate state:
function replay() {
const el = document.getElementById("preview");
el.style.animation = "none";
// ★ Force reflow — without this, the browser batches the two style changes
// and concludes the final state is unchanged, so nothing restarts.
void el.offsetWidth;
el.style.animation = ""; // back to whatever the stylesheet says
update();
}
The void el.offsetWidth line is the trick. Reading any layout-affecting property (offsetWidth, clientHeight, getBoundingClientRect()) flushes pending style mutations into the layout tree before the next JS statement runs. Without it the engine batches the "set to none" and "set to original" into one operation, sees the final result is identical, and skips the animation restart entirely.
This trick gets passed around as a one-liner without explanation. The why: CSS animation restart is triggered by a transition in the animation declaration, not by the final value being non-default. The forced reflow guarantees a real transition occurs in the engine's state machine.
Easing: cubic-bezier back-out
One of the preset easings is cubic-bezier(0.34, 1.56, 0.64, 1) — "back-out." It produces an overshoot-then-settle motion, ideal for popping UI elements in.
The cubic-bezier(x1, y1, x2, y2) four values are the two control points of the curve. y > 1 means the curve overshoots. The 1.56 here is "56% beyond the target before settling back." Material Design and iOS spring animations all live in this family — they're not magic, just bezier curves with y > 1.
Users don't need to know any of that. They click a preset or pick "back-out" from the dropdown. The numerics are the implementation detail; the behavior is the API.
Deleting and ordering keyframes
frames is an array of { stop, props }. Deleting a frame is a plain splice:
state.frames.splice(index, 1);
renderFrames();
update();
The annoying part: stop values don't match array order. If the user changes a 50% frame to 75%, the array stays as [0, 75, 100] (in original insertion order, though the example happens to be sorted), but the UI should always show 0, 50/75, 100 top to bottom.
Solve it by sorting the display while preserving the storage index for callbacks:
function renderFrames() {
const container = $("framesContainer");
container.innerHTML = "";
state.frames
.map((f, i) => ({ f, i })) // capture original index
.sort((a, b) => a.f.stop - b.f.stop) // sort for display only
.forEach(({ f, i }) => container.appendChild(renderFrame(f, i)));
}
The renderFrame callback uses i to write back to state.frames[i].stop, so position-in-array is preserved as the source of truth and "sorted for display" is just rendering.
structuredClone for preset state
const state = structuredClone(PRESETS.bounce);
structuredClone does a real deep copy — nested objects and arrays in frames[].props all get duplicated. This means:
- Editing
statedoesn't mutate the preset - Picking a preset is
Object.assign(state, structuredClone(PRESETS[name]))and the preset stays pristine for next time
It's been in all browsers since 2022. No JSON.parse(JSON.stringify()) workaround needed.
Tests as the design boundary
test("stops are emitted in ascending order regardless of input order", () => {
const out = buildKeyframes("bounce", [
{ stop: 100, props: { transform: "translateY(0)" } },
{ stop: 0, props: { transform: "translateY(0)" } },
{ stop: 50, props: { transform: "translateY(-40px)" } },
]);
const lines = out.split("\n");
assert.ok(lines[1].includes("0%"));
assert.ok(lines[2].includes("50%"));
assert.ok(lines[3].includes("100%"));
});
test("iterations of 1 are omitted from the shorthand (it's the default)", () => {
assert.equal(
buildAnimationDecl({ name: "fade", duration: 1, easing: "ease", iterations: 1 }),
"fade 1s ease",
);
});
Because the generator is pure, every edge case (empty frames, null prop values, stop at exactly 100, cubic-bezier easing strings) is unit-testable without a browser. npm test runs in 70ms.
Takeaways
-
Generating
@keyframesstrings is pure logic — DOM-free, perfectly testable. - Sort stops inside the generator, not in the UI — the state can stay in insertion order while the output stays canonical.
-
Inject a
<style>tag for live preview instead of settingelement.style.animation*— gets you DevTools "Animations" inspector for free. -
void el.offsetWidthis the documented hack for restarting an animation that's already running with identical config. The reason: forced reflow flushes the intermediateanimation: nonestate through the engine. -
cubic-bezier(_, y>1, _, _)is the family of overshoot easings.(0.34, 1.56, 0.64, 1)= Material spring. -
structuredCloneis the modern preset-copy primitive — kills offJSON.parse(JSON.stringify())for state.
This is OSS portfolio #244 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/

Top comments (0)