CSS の
@keyframesを書くとき、50% { transform: translateY(-40px); }の-40を毎回変えてはブラウザリロード、しっくりこない、もう一回… を繰り返している。タイムライン UI で stop と各プロパティを編集 → ライブプレビュー → 生成 CSS をコピーする、500 行 vanilla JS のツールを書いた。実装中に「同じ animation 名を再適用しても再生されない」という有名なワナにハマったので、その対処も含めて解説する。
🌐 Demo: https://sen.ltd/portfolio/css-animation-designer/
📦 GitHub: https://github.com/sen-ltd/css-animation-designer
設計: ジェネレータと UI を分離
keyframes.js ← @keyframes / animation 短縮プロパティの文字列生成(DOM 非依存)
presets.js ← bounce / fade / spin など 6 種のプリセット config
app.js ← UI グルー(state 編集 → keyframes.js → <style> 注入)
keyframes.js には document も <style> も登場しない。{ name, duration, easing, frames: [{ stop, props }] } というオブジェクトを受け取って CSS 文字列を返すだけ。
export function buildKeyframes(name, frames) {
if (!frames || frames.length === 0) return `@keyframes ${name} {}`;
// stop でソートしておけば入力順に依存しない決定的な出力
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}`;
}
stop でソートする理由: ユーザは UI 上 100% → 0% の順に追加するかもしれない。だが生成 CSS は常に昇順で出したい。並びを保証するロジックは生成側に置く ことで、UI 層は state の順序を気にしなくて済む。
animation 短縮プロパティは:
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(" ");
}
iterations: 1 と fillMode: "none" はデフォルトなので 出力に含めない。これだけで出力 CSS が「実際にコピペしたいきれいなコード」になる。
ライブプレビュー: <style> タグ注入
普通のアプローチは要素に直接 style.animationName などを設定すること。だがそれだと:
- DevTools の "Animations" インスペクタで
@keyframesが表示されない - 複数プロパティを同期させる必要がある
代わりに <style> タグを 1 個用意して中身を書き換える とブラウザの CSS パーサが本物の @keyframes ルールとして認識する:
const styleTag = document.createElement("style");
styleTag.id = "live-animation";
document.head.appendChild(styleTag);
function update() {
const css = buildFullCSS(state, "#preview");
styleTag.textContent = css; // すべて入れ替え
}
このとき生成 CSS は @keyframes bounce {...} + #preview { animation: bounce 1s ...; } の両方を含む。styleTag.textContent = css; の一行で両方反映される。
ワナ: 「同じ名前のアニメーションは再生されない」
@keyframes bounce を更新して #preview に同じ animation: bounce ...; を再適用すると、ブラウザは「同じだから何もしない」 と判断してアニメーションが再生されない。これは CSS Animations Level 1 の仕様で、animation-name を含む animation declaration が値として同一だと再起動が発生しない。
「再生ボタン」を実装するには、いったん animation を外して 強制 reflow してから戻す必要がある:
function replay() {
const el = document.getElementById("preview");
el.style.animation = "none";
// ★ 強制 reflow — これがないとブラウザが optimisation で animation 削除をスキップする
void el.offsetWidth;
el.style.animation = ""; // CSS のルールに戻す
update();
}
void el.offsetWidth のところがミソ。レイアウト計算を強制トリガすることで、ブラウザは "animation: none" と "animation: " を 別の状態として認識 する。これがないと連続して同じ animation を当てたつもりが何も起きない、という症状が出る。
このトリックは MDN / CSS Tricks で何度か紹介されているが、なぜ動くのか書いてある記事は少ない。ブラウザが style 変更を batch する → animation declaration が「同じ」と判定されて update が skip される → 強制 reflow すると batch が flush されて差分が確定する という流れ。
イージング: cubic-bezier の back-out
プリセットの easing に cubic-bezier(0.34, 1.56, 0.64, 1) (back-out) を入れた。これは「目的地をオーバーシュートしてから戻る」動きで、ease-out より遊びがあって UI 要素のポップアップに最適。
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
cubic-bezier の 4 引数は P1.x, P1.y, P2.x, P2.y。y が 1 を超える と「目的地を一度通り過ぎる」挙動になる。1.56 = 56% オーバーシュート、64% で減速開始、と読める。Material Design や iOS の "spring" アニメーションはこのファミリ。
ユーザは数値を覚えなくていい — 本ツールでプリセット選ぶか、select で back-out を選ぶだけ。
キーフレーム削除と並び順
frames: [{ stop, props }] を配列で持っているので、削除は単純な splice:
state.frames.splice(index, 1);
renderFrames();
update();
問題は stop 値が表示順とずれる こと。「50% を 75% に変えた」みたいな操作で、配列上は [0, 75, 100] という順番だけど、UI 上は 0, 75, 100 で並べたい。
renderFrames() の中でソートして表示:
function renderFrames() {
const container = $("framesContainer");
container.innerHTML = "";
state.frames
.map((f, i) => ({ f, i })) // インデックスを保持
.sort((a, b) => a.f.stop - b.f.stop) // 表示用にソート
.forEach(({ f, i }) => container.appendChild(renderFrame(f, i)));
}
ポイント: 元の配列インデックスを保持してソート。input イベントで state.frames[index] を更新するときに正しい要素を指す必要があるので、表示順とインデックスの対応を保つ。
state の型を後で広げやすくする
const state = structuredClone(PRESETS.bounce);
structuredClone でプリセットを深いコピーする。これにより:
- プリセット定義は変更されない(次にユーザが押したときも初期状態のまま)
- state は自由に編集できる
- 配列の入れ子もちゃんとコピーされる(
JSON.parse(JSON.stringify(...))の代替)
ES2022 で全ブラウザサポートされた構文で、外部依存なしに使える。
14 件のテスト
test("multiple keyframes render in stop 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 (default)", () => {
assert.equal(
buildAnimationDecl({ name: "fade", duration: 1, easing: "ease", iterations: 1 }),
"fade 1s ease",
);
});
test("infinite iteration renders as 'infinite'", () => {
assert.equal(
buildAnimationDecl({ name: "spin", duration: 2, easing: "linear", iterations: "infinite" }),
"spin 2s linear infinite",
);
});
生成側を pure に保てば「同じ入力 → 同じ出力」が完全に保証されるので、UI のエッジケース (空 frames、null prop value、stop = 100、cubic-bezier 含む easing 文字列) を全部 Node テストでカバーできる。
まとめ
-
@keyframes文字列を作るだけのジェネレータは pure 関数で書ける → DOM なしで全パターンをテスト可能 - stop はジェネレータ内でソート すると UI 層が並びを気にしなくていい
- デフォルト値 (iterations=1, fillMode=none) は出力から省く ことできれいな CSS になる
-
ライブプレビューは
<style>タグ注入 が一番きれい — DevTools の Animations インスペクタも使える -
「同じ animation 名は再生されない」ワナ は
void el.offsetWidthで reflow を強制すれば回避できる -
cubic-bezier の y > 1 がオーバーシュート (back-out) の正体。
(0.34, 1.56, 0.64, 1)がそのまま Material Design の spring
リポジトリ: https://github.com/sen-ltd/css-animation-designer
このツールは弊社の OSS ポートフォリオ #244 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/
