DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building a CSS @keyframes animation builder: inject a live <style> and the none reflow re-add replay trick

A CSS @keyframes rule is not a movie. It is a short list of snapshots, each pinned to a percentage of the animation, and the browser interpolates every frame in between. Internalise "snapshot at a percentage" and CSS animation stops feeling magical. I built a little tool that lets you drag keyframe stops on a timeline, tune each one, watch it play, and copy the exact rule — all in vanilla JS with a dynamically injected <style> tag. It has one genuinely clever move and a lot of easy plumbing; here's the clever move.

The whole state is a name plus a list of stops

An animation is a named rule plus playback settings, and inside it a list of stops. Each stop is one percentage and the property values the element should have there. Keep it all in one plain object — that object is your document. Note that the playback fields live here too but they belong to the animation shorthand, not inside @keyframes.

let anim = {
  name: "myAnim",
  duration: 1500,        // ms — one loop
  timing: "ease",
  iteration: "infinite",
  direction: "normal",
  stops: [
    { pct:0,   tx:0, ty:0, scale:1,   rotate:0,   opacity:1,  bg:"#f59e0b" },
    { pct:50,  tx:0, ty:0, scale:1.4, rotate:180, opacity:.6, bg:"#3b82f6" },
    { pct:100, tx:0, ty:0, scale:1,   rotate:0,   opacity:1,  bg:"#f59e0b" }
  ]
};
Enter fullscreen mode Exit fullscreen mode

Serialise stops into a rule

Turn each stop into the CSS that goes inside its percentage braces, then wrap them under @keyframes name. One detail matters: all the movement, size and tilt live in a single transformtranslate, then scale, then rotate — because a second transform line would overwrite the first, and the order inside it changes the result (rotate-then-translate moves along the rotated axis, which is usually not what you want).

function stopBody(s){
  return `transform: translate(${s.tx}px, ${s.ty}px) scale(${s.scale}) rotate(${s.rotate}deg); `
       + `opacity: ${s.opacity}; background: ${s.bg};`;
}
function toKeyframes(a){
  const rows = a.stops.slice().sort((x, y) => x.pct - y.pct)
    .map(s => `  ${s.pct}% { ${stopBody(s)} }`).join("\n");
  return `@keyframes ${a.name} {\n${rows}\n}`;
}
Enter fullscreen mode Exit fullscreen mode

Write CSS at runtime — the injected </h2> <p>This is what makes the preview <em>real</em>. Create one <code>&lt;style&gt;</code> element, keep a handle to it, and overwrite its <code>textContent</code> with the freshly generated rule whenever the state changes. The browser&#39;s actual CSS engine now owns the interpolation — you never compute a single in-between frame yourself. Three canvas shapes can share the one named animation, and editing a stop re-injects the rule for all of them.<br> </p> <div class="highlight"><pre class="highlight javascript"><code><span class="kd">const</span> <span class="nx">styleEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">style</span><span class="dl">"</span><span class="p">);</span> <span class="nb">document</span><span class="p">.</span><span class="nx">head</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">styleEl</span><span class="p">);</span> <span class="c1">// one tag, reused forever</span> <span class="kd">function</span> <span class="nf">writeRule</span><span class="p">(){</span> <span class="nx">styleEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nf">toKeyframes</span><span class="p">(</span><span class="nx">anim</span><span class="p">);</span> <span class="p">}</span> </code></pre></div> <p></p> <h2> <a name="the-one-real-gotcha-none-→-reflow-→-readd" href="#the-one-real-gotcha-none-→-reflow-→-readd" class="anchor"> </a> The one real gotcha: none → reflow → re-add </h2> <p>Here&#39;s the trap that trips everyone. A browser will <strong>not</strong> pick up changes to a <code>@keyframes</code> rule for an animation that is already running, and re-assigning the same <code>animation</code> string does nothing either. So after every edit you have to force a restart: set <code>animation</code> to <code>none</code>, then <em>read</em> a layout property (<code>offsetWidth</code>) to force a synchronous reflow so the browser commits the &quot;no animation&quot; state, then set the animation back. Without that middle line the two assignments collapse into one and nothing restarts.<br> </p> <div class="highlight"><pre class="highlight javascript"><code><span class="kd">function</span> <span class="nf">apply</span><span class="p">(</span><span class="nx">el</span><span class="p">){</span> <span class="nf">writeRule</span><span class="p">();</span> <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">animation</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">none</span><span class="dl">"</span><span class="p">;</span> <span class="k">void</span> <span class="nx">el</span><span class="p">.</span><span class="nx">offsetWidth</span><span class="p">;</span> <span class="c1">// &lt;-- force reflow (the crux)</span> <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">animation</span> <span class="o">=</span> <span class="nx">anim</span><span class="p">.</span><span class="nx">name</span> <span class="o">+</span> <span class="dl">"</span><span class="s2"> </span><span class="dl">"</span> <span class="o">+</span> <span class="p">(</span><span class="nx">anim</span><span class="p">.</span><span class="nx">duration</span><span class="o">/</span><span class="mi">1000</span><span class="p">)</span> <span class="o">+</span> <span class="dl">"</span><span class="s2">s </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">anim</span><span class="p">.</span><span class="nx">timing</span> <span class="o">+</span> <span class="dl">"</span><span class="s2"> </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">anim</span><span class="p">.</span><span class="nx">iteration</span> <span class="o">+</span> <span class="dl">"</span><span class="s2"> </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">anim</span><span class="p">.</span><span class="nx">direction</span><span class="p">;</span> <span class="p">}</span> </code></pre></div> <p></p> <p>That single <code>void el.offsetWidth</code> is the whole reason the tool can show your edits live. It&#39;s one of those lines that looks like dead code and is actually load-bearing.</p> <h2> <a name="everything-else-is-data" href="#everything-else-is-data" class="anchor"> </a> Everything else is data </h2> <p>Once the replay works, the rest is plumbing. The draggable timeline is one <code>&lt;div&gt;</code> with a marker per stop positioned by <code>left: pct%</code>; on pointer-move I convert the cursor&#39;s x into 0–100 and write it back onto that stop. &quot;Add stop&quot; finds the widest gap between consecutive percentages and drops a new stop in the middle, cloning its left neighbour so nothing visually jumps. Play/pause flips <code>animation-play-state</code> between <code>running</code> and <code>paused</code> so the element freezes exactly where it is. And every preset — pulse, bounce, shake, spin — is just a canned list of stops you drop straight into state.</p> <p>Two things worth burning in that the tool surfaces along the way: percentages are portions of <em>one loop</em>, not seconds (change the duration and the stops stay put while the real time stretches), and animating <code>transform</code> / <code>opacity</code> is the fast path because the compositor handles it with no layout — prefer <code>translate</code> over <code>top/left</code>, <code>scale</code> over <code>width/height</code>.</p> <p>The lesson underneath: a keyframe animation is not frames you draw. It&#39;s a handful of snapshots at percentages, the browser tweens everything between, and your job is just to write the CSS and restart it.</p> <p>Drag the stops and copy the rule:<br> <a href="https://dev48v.infy.uk/solve/day37-keyframes-builder.html">https://dev48v.infy.uk/solve/day37-keyframes-builder.html</a></p>

Top comments (0)