DEV Community

SEN LLC
SEN LLC

Posted on

A Visual CSS @keyframes Editor in 500 Lines — Plus the 'Same Animation Won't Restart' Trap and How to Fix It

Every time I write a CSS @keyframes rule 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 same animation declaration 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

Screenshot

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
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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(" ");
}
Enter fullscreen mode Exit fullscreen mode

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 @keyframes rule
  • 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: ... }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

structuredClone does a real deep copy — nested objects and arrays in frames[].props all get duplicated. This means:

  • Editing state doesn'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",
  );
});
Enter fullscreen mode Exit fullscreen mode

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 @keyframes strings 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 setting element.style.animation* — gets you DevTools "Animations" inspector for free.
  • void el.offsetWidth is the documented hack for restarting an animation that's already running with identical config. The reason: forced reflow flushes the intermediate animation: none state through the engine.
  • cubic-bezier(_, y>1, _, _) is the family of overshoot easings. (0.34, 1.56, 0.64, 1) = Material spring.
  • structuredClone is the modern preset-copy primitive — kills off JSON.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)