テック技報最新号へ →
CSS

PropFLIP:@property 4つと transition でレイアウト変化を FLIP アニメーションする

FLIP の「Invert」を @property で型付けした4つのカスタムプロパティ(--tx --ty --sx --sy)に入れ、「Play」を CSS の transition に任せると、途中で割り込める・軸ごとにイージングを変えられるレイアウトアニメーションになる。

@argyleink · 投稿 2026-11-10

どんな時に使う?

カードがグリッドの別のセルへ移る、リストが並び替わる、箱が大きくなる、といった「レイアウトが変わる」動きを滑らかに見せたいとき。top/left/width/height を transition すると毎フレームレイアウトが走って重い。transform は軽いが「どこへ行くか」はレイアウト次第なので、変化前後の位置を JS で測って差分を transform で埋める(FLIP)のが定番。PropFLIP はその差分を @property に入れることで、アニメーション部分を CSS 側に寄せる。

  • アニメーション中にもう一度動かしても自然につながる(transition なので割り込み可能)
  • x・y・横スケール・縦スケールに別々の duration / easing / delay を付けられる(x と y のタイミングをずらすと弧を描く動きになる)
  • 角丸と中身の文字が伸び縮みしない補正ができる

コード

CSS

css
/* 4つの型付きプロパティ。スケールは子の補正に使うので inherits: true */
@property --tx { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --ty { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --sx { syntax: "<number>"; inherits: true;  initial-value: 1; }
@property --sy { syntax: "<number>"; inherits: true;  initial-value: 1; }

.flip {
  --r: 16px; /* 角丸 */
  --p: 16px; /* padding */
  padding: var(--p);
  transform:
    translate(calc(var(--tx) * 1px), calc(var(--ty) * 1px))
    scale(var(--sx), var(--sy));
  transform-origin: top left; /* getBoundingClientRect() の基準に合わせる */

  transition: --tx, --ty, --sx, --sy;
  transition-duration: .6s, 1s, .4s, .4s;           /* プロパティごとに変えられる */
  transition-timing-function: ease-out, cubic-bezier(.3, 1.5, .5, 1), ease, ease;

  /* 角丸がスケールで楕円にならないよう割り戻す */
  border-radius: calc(var(--r) / var(--sx)) / calc(var(--r) / var(--sy));
}

/* 直下の子だけ逆スケールで文字の伸びを打ち消す(孫はそのまま付いてくる) */
.flip > * {
  transform-origin: top left;
  transform:
    translate(calc(var(--p) / var(--sx) - var(--p)),
              calc(var(--p) / var(--sy) - var(--p)))
    scale(calc(1 / var(--sx)), calc(1 / var(--sy)));
}

JS(FLIP の手順)

js
function animateLayout(el, updateDOM) {
  const first = el.getBoundingClientRect();   // F: 変化前を測る
  el.style.transition = 'none';               // いったん transition を止める
  updateDOM();                                // DOM を変える
  const last = el.getBoundingClientRect();    // L: 変化後を測る

  // I: 差分(位置は引き算、大きさは割り算)を入れて見た目を元の位置に戻す
  el.style.setProperty('--tx', first.left - last.left);
  el.style.setProperty('--ty', first.top - last.top);
  el.style.setProperty('--sx', first.width / last.width);
  el.style.setProperty('--sy', first.height / last.height);

  el.offsetHeight;                            // スタイルを確定させる(これがないと動かない)
  el.style.transition = '';                   // P: transition を戻して 0/1 へ
  el.style.setProperty('--tx', 0);
  el.style.setProperty('--ty', 0);
  el.style.setProperty('--sx', 1);
  el.style.setProperty('--sy', 1);
}

button.onclick = () =>
  animateLayout(box, () => stage.toggleAttribute('data-last'));

注意点・対応状況

  • @property と、登録したカスタムプロパティの transition は主要ブラウザで使える(Baseline)。
  • 登録カスタムプロパティのアニメーションはコンポジタに渡されず、メインスレッドで毎フレームスタイル再計算が走る。--sx/--sy は継承するので子孫も再計算の対象になる。カード1枚・ダイアログ・短いリスト程度向き。数百要素や重いページでは element.animate() で transform を直接動かす方が安全。
  • 子の逆スケール補正は「子が親の padding の位置に並んでいる」前提。複雑なレイアウトでは追加の計算が必要。
  • prefers-reduced-motion: reduce のときは transition を外して即座に移動させる。
  • ページ全体の遷移なら View Transitions も候補(ガイド: CSSだけで作るアニメーション 参照)。

出典

  • @argyleink(2026-11-10、解説記事 nerdy.dev「PropFLIP」)
X の元の投稿を開く