1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSS @keyframes をビジュアル編集するツールを作った — タイムライン UI と「アニメーションを再起動する」CSS のワナ

1
Posted at

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

Screenshot

設計: ジェネレータと 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: 1fillMode: "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" アニメーションはこのファミリ。

ユーザは数値を覚えなくていい — 本ツールでプリセット選ぶか、selectback-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/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?