前回の記事では、View Transitions の仕様をもとに、基本的な仕組みについて説明しました。

今回は応用篇として、アニメーションのカスタマイズ方法をはじめ、実際にいくつかのデモを作成した過程で気づいたポイントを説明していきます。

本記事で紹介する、View Transitions の機能は、W3C の仕様では「CSS View Transitions Module Level 1」もしくは、「CSS View Transitions Module Level 2」に属しています。

また、View Transitions は、同一ドキュメント内の遷移(same-document transitions)とクロスドキュメントの遷移(cross-document transitions)に分類できますが、この記事ではおもに前者にフォーカスします。

仕様に関しては、現時点では Editor’s Draft のため、今後、実装方法やブラウザの挙動が変わる可能性がある点には注意が必要です。

アニメーションのカスタマイズ

見出し「アニメーションのカスタマイズ」

以下は「カラー変更」ボタンを選択するたびに、背景色がランダムに切り替わるデモです。

Live Demo

簡略化したコードは以下ですが、View Transition のコールバック関数で背景色を変更しています。

背景色がランダムに切り替わるデモ
<!-- HTML -->
<div class="color">
  <div class="color-target" data-vt="color"></div>
</div>
<button type="button" data-vt="color-btn">カラー変更</button>

<!-- CSS -->
<style>
.color {
  margin-block: 20px;
  inline-size: 200px;
  aspect-ratio: 1;
  border: 2px solid black;
}
.color-target {
  inline-size: 100%;
  block-size: 100%;
  background-color: white;
}
</style>

<!-- JS -->
<script>
const changeColor = () => {
  const updateCallback = () => {
    const target = document.querySelector('[data-vt="color"]');
    const getRand = (min, max) => Math.floor(Math.random() * (max - min) + min);

    // `style` 属性をランダムな値に変更
    target?.style.setProperty('background',
      `hsl(${getRand(0, 360)} ${getRand(50, 100)}% ${getRand(50, 100)}%)`);
  };

  // フォールバック
  if (!document.startViewTransition) {
    updateCallback();
    return;
  }

  // View Transition 開始
  document.startViewTransition(() => updateCallback());
};

const btn = document.querySelector('[data-vt="color-btn"]');
btn?.addEventListener('click', () => changeColor());
</script>

ここでは、特にアニメーションを指定していないので、UA スタイルシートで定義されているデフォルトのスタイルが適用されます。また、変化するのは一部ですが、view-transition-name を指定していないので、:root (ドキュメント全体)がアニメーションの対象になっています。

このデフォルトのクロスフェードのアニメーションを、スライドインにカスタマイズしてみます。

Live Demo

さきほどのコードの CSS を以下に変更しました。

スライドインのアニメーションの CSS
<!-- CSS -->
<style>
.color {
  margin-block: 20px;
  inline-size: 200px;
  aspect-ratio: 1;
  border: 2px solid black;
}
.color-target {
  view-transition-name: color;
  inline-size: 100%;
  block-size: 100%;
  background-color: white;
}
::view-transition-group(color) {
  overflow: clip;
}
::view-transition-old(color) {
  animation: none;
}
::view-transition-new(color) {
  animation-name: translateYFromTop;
}
@keyframes translateYFromTop {
  from {
    translate: 0 -100%;
  }
}
</style>

まず、アニメーションさせる要素に、view-transition-name: color でグループ名を指定しています。これにより、キャプチャする要素が追加されます。

擬似要素のツリーの構造
::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(color)
  └─ ::view-transition-image-pair(color)
      ├─ ::view-transition-old(color)
      └─ ::view-transition-new(color)

ここでのポイントとしては、アニメーションを指定するのは要素自身(.color-target)ではなく、View Transition の擬似要素(::view-transition-*)に指定しているということです。

スライドインのアニメーションは、DOM 変更後(new)の擬似要素に translate プロパティを変化させることで実現しています。ただ、このままでは外にはみ出してしまうので、グループ要素に対して overflow: clip を指定しています。

加えて、DOM 変更前(old)の擬似要素に対して、UA スタイルシートのフェードアウト効果を無効にするために、animation: none を指定しています。

ここで、さきほどのカラー変更のコンポーネントに、ボタンを選択するとカウントダウンするコンポーネントを追加します。この状態で、「カラー変更」と「カウントダウン」のボタンを選択すると、それぞれの挙動はどうなるでしょうか。

Live Demo
100

簡略化したコードは以下ですが、コード量が多いため折りたたんでいます。

コードを確認する
<!-- HTML -->
<div class="stage">
  <div class="color">
    <div class="color-target" data-vt="color"></div>
  </div>
  <div class="count">
    <output class="count-target" data-vt="count">100</output>
  </div>
  <button type="button" data-vt="color-btn">カラー変更</button>
  <button type="button" data-vt="count-btn">カウントダウン</button>
</div>

<!-- CSS -->
<style>
@layer base, components, vt;

@layer vt {
  /* View Transition (color) */
  .color-target {
    view-transition-name: color;
  }
  ::view-transition-group(color) {
    overflow: clip;
  }
  ::view-transition-old(color) {
    animation: none;
  }
  ::view-transition-new(color) {
    animation-name: scaleYFromTop;
  }
  @keyframes scaleYFromTop {
    from {
      translate: 0 -100%;
    }
  }

  /* View Transition (countdown) */
  .count-target {
    view-transition-name: countdown;
  }
  ::view-transition-group(countdown) {
    overflow: clip;
  }
  ::view-transition-old(countdown) {
    animation-name: slideToBottom;
  }
  ::view-transition-new(countdown) {
    animation-name: slideFromTop;
  }
  @keyframes slideToBottom {
    to {
      translate: 0 100%;
    }
  }
  @keyframes slideFromTop {
    from {
      translate: 0 -100%;
    }
  }
}

@layer components {
  .color,
  .count {
    display: grid;
    place-items: center;
    inline-size: 200px;
    aspect-ratio: 1;
    border: 2px solid black;
  }
  .color-target {
    inline-size: 100%;
    block-size: 100%;
    background-color: white;
  }
  .count {
    background-color: hsl(180 10% 60%);
  }
  .count-target {
    font-size: 2rem;
  }
}

@layer base {
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  button {
    padding: 8px;
    inline-size: min(90vi, 200px);
  }
  .stage {
    display: grid;
    grid-template-columns:repeat(auto-fit, minmax(15em, 1fr));
    align-content: center;
    justify-items: center;
    gap: 24px;
    inline-size: min(100%, 720px);
    block-size: 100%;
    margin: auto;
    padding: 40px;
  }
}
</style>

<!-- JS -->
<script>
// カラー変更
const changeColor = () => {
  const update = () => {
    const target = document.querySelector('[data-vt="color"]');
    const getRand = (min, max) => Math.floor(Math.random() * (max - min) + min);
    target?.style.setProperty('background',
      `hsl(${getRand(0, 360)} ${getRand(50, 100)}% ${getRand(50, 100)}%)`);
  };

  if (!document.startViewTransition) {
    update();
    return;
  }
  document.startViewTransition(() => update());
};

// カウントダウン
const countdown = () => {
  const update = () => {
    const output = document.querySelector('[data-vt="count"]');
    if (!output || !output.textContent) return;

    let value = parseInt(output.textContent, 10) ?? 0;

    if (value < 1) {
      output.textContent = '100';
    } else {
      output.textContent = String(value - 1).padStart(3, '0');
    }
  };

  if (!document.startViewTransition) {
    update();
    return;
  }
  document.startViewTransition(() => update());
};

const colorBtn = document.querySelector('[data-vt="color-btn"]');
colorBtn?.addEventListener('click', () => changeColor());

const countdownBtn = document.querySelector('[data-vt="count-btn"]');
countdownBtn?.addEventListener('click', () => countdown());
</script>

「カウントダウン」を選択すると、スライドするアニメーションとともに、数字がカウントダウンされます。これは意図した挙動です。

一方で、「カラー変更」を選択すると、背景色がスライドインで切り替わりますが、カウントダウンのアニメーションも連動してしまいます。

このとき、擬似要素のツリーは以下のように表すことができます。

擬似要素のツリーの構造
::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
├─ ::view-transition-group(color)
│ └─ ::view-transition-image-pair(color)
│     ├─ ::view-transition-old(color)
│     └─ ::view-transition-new(color)
└─ ::view-transition-group(countdown)
  └─ ::view-transition-image-pair(countdown)
      ├─ ::view-transition-old(countdown)
      └─ ::view-transition-new(countdown)

このように、一つの ::view-transition のサブツリーとして追加されます。そのため、View Transition を開始するたびに、すべての ::view-transition-* のスタイルが有効になり、アニメーションが連動してしまいます。

実際には、「カウントダウン」を選択したときにも、「カラー変更」のスライドインのアニメーションが実行されているのですが、色の変化がないので見た目上ではわからないだけです。

この問題を回避するには、以下の 2 つの方法が考えられます1

  • types を使用する
  • カスタムデータ属性(data-*)を使用する

なお、執筆時点では Firefox がtypes に対応していません。

types を使用する方法

この見出しのリンク

こちらの方法では、まず、startViewTransition() メソッドにオプションを追加します。update には、これまでと同じようにコールバック関数を指定し、types には、識別子となる文字列を配列形式で指定します。

JS の抜粋
// View Transition の初期設定
const initVt = (update, types) => {
  // フォールバック
  if (!document.startViewTransition || !ViewTransition || !('types' in ViewTransition.prototype)) {
    update();
    return;
  }

  // オプションを指定
  document.startViewTransition({
    update,
    types,
  });
};

// カラー変更
const changeColor = () => {
  const update = () => {
    // ...
  }
  initVt(update, ['color']);
};

// カウントダウン
const countdown = () => {
  const update = () => {
    // ...
  }
  initVt(update, ['countdown']);
};

types に対応していない環境では JS エラーが発生してしまうので、フォールバックの if 文を調整していますが、以下のように try...catch 文のほうがシンプルかもしれません。

try...catch でフォールバックする例
try {
  document.startViewTransition({
    update,
    types,
  });
} catch (e) {
  update();
}

続いて、view-transition-name の指定を含む CSS のセレクタを調整します。

CSS の抜粋
:root:active-view-transition-type(color) {
  .color-target {
    view-transition-name: color;
  }
}
/* ... */
:root:active-view-transition-type(countdown) {
  .count-number {
    view-transition-name: countdown;
  }
}

このように、:active-view-transition-type() セレクタに指定した types が、startViewTransition() のオプションで指定した types と一致するときだけ、スタイルを有効にすることができます。

ちなみに types には複数の値が指定可能で、ナビゲーションの方向によってアニメーションの種類を切り替えたいときなどにも使用できます(というより、こちらが王道の使い方のようです)。

カスタムデータ属性(data-*)を使用する方法

見出し「カスタムデータ属性(」

対して、こちらの方法は、前述したように執筆時点では Firefox が types に対応していないので、それをポリフィルするようなアプローチです。

まず、アニメーションが開始する前に、識別子となるカスタムデータ属性(data-*)を指定し、viewTransition.finished のタイミングで、そのカスタムデータ属性を削除します。

JS の抜粋
// View Transition がアクティブなときにカスタムデータ属性(`data-*`)を付与
const setVtType = async (transition, type) => {
  try {
    document.documentElement.setAttribute('data-vt-type', type);
    await transition.finished;
  } finally {
    document.documentElement.removeAttribute('data-vt-type');
  }
};

// View Transition の初期設定
const initVt = (update, type) => {
  // フォールバック
  if (!document.startViewTransition) {
    update();
    return;
  }

  const transition = document.startViewTransition(() => update());
  setVtType(transition, type);
};

// カラー変更
const changeColor = () => {
  const update = () => {
    // ...
  }
  initVt(update, 'color');
};

// カウントダウン
const countdown = () => {
  const update = () => {
    // ...
  }
  initVt(update, 'countdown');
};

続いて、先ほどと同様に CSS のセレクタを調整します。

CSS の抜粋
:root[data-vt-type="color"] {
  .color-target {
    view-transition-name: color;
  }
}
/* ... */
:root[data-vt-type="countdown"] {
  .count-number {
    view-transition-name: countdown;
  }
}

これで、お互いのアニメーションが干渉しなくなりました2

Live Demo
100

ただ、こちらの方法では、types のように複数の値が指定できません。そのため、属性値を半角スペースで区切って、セレクタに ~(チルダ)を使用するといった対応が必要になります。

半角スペースで区切られた値に対応する CSS の例
/* `data-vt-type="slide forwards"` */
:root[data-vt-type~="slide"][data-vt-type~="forwards"] {
  .target {
    view-transition-name: slide-in;
  }
}

/* `data-vt-type="slide backwards"` */
:root[data-vt-type~="slide"][data-vt-type~="backwards"] {
  .target {
    view-transition-name: slide-out;
  }
}

view-transition-name の重複

この見出しのリンク

前回の記事でも少し触れましたが、view-transition-name は、各要素に対してユニークである必要があります。同じ名前が指定されている要素が複数存在すると、DOM の変更は実行されますが、すべての View Transition が無効になってしまいます。

view-transition-name が重複している例
<!-- HTML -->
<ul class="card-list">
  <li>foo</li>
  <li>bar</li>
  <li>baz</li>
</ul>

<!-- CSS -->
<style>
/* ❌ 複数の要素に同じ名前を指定 */
.card-list > li {
  view-transition-name: card;
}
::view-transition-old(card) {
  animation: none;
}
::view-transition-new(card) {
  animation-name: flip;
}
</style>

デベロッパーツールでコンソールを確認すると、Unexpected duplicate view-transition-name といったエラーが表示されます。

ただ、重複しなければよいので、この例であれば、HTML 上の <li> 要素が 1 つであればエラーは発生しません。加えて、display: none が適用されている要素はカウントされないようです。

このとき、要素ごとにユニークな名前を付けることでも対応できますが、match-element キーワードを使用することで、よりシンプルに解決できます。

match-element キーワードを使用した例
<!-- HTML -->
<ul class="card-list">
  <li>foo</li>
  <li>bar</li>
  <li>baz</li>
</ul>

<!-- CSS -->
<style>
/* ⭕️ `match-elemnt` キーワードを指定 */
.card-list > li {
  view-transition-name: match-element;
  view-transition-class: card;
}
::view-transition-old(.card) {
  animation: none;
}
::view-transition-new(.card) {
  animation-name: flip;
}
</style>

まず、view-transition-name プロパティに対して、match-element キーワードを指定するだけで、ブラウザ側でユニークな名前を付与してくれます。

ただ、このままでは ::view-transition-group() などの擬似要素にグループ名を指定することができないので、view-transition-class でクラス名を指定します。

そして、このクラス名を、通常の CSS セレクタのように .(ドット)で指定することで、擬似要素に対してスタイルを指定することができるようになります。

ちなみに、Safari で View Transition API がサポートされたのは 18.0 からですが、match-element は 18.4、view-transition-class は 18.2 と、微妙にサポートされたバージョンが異なります。

モーフィングアニメーション

見出し「モーフィングアニメーション」

View Transition API を使用することで、モーフィングのように、スムーズに形状を変化させるアニメーションを比較的容易に実装できます。

ここでは、2 つのデモをもとに具体的に見ていきます。

まず、以下のデモでは、「前へ」「次へ」ボタンを選択すると、グリッドに配置されたアイテムがローテーションしますが、その際、アイテムのサイズや位置がスムーズに変化します。

Live Demo
画像 1
画像 2
画像 3
画像 4
画像 5
250

簡略化したコードは以下に折りたたんでいます。なお、「前へ」ボタンは割愛しており、各アイテムは画像ではなく背景色にしています。

コードを確認する
<!-- HTML -->
<div class="stage">
  <div class="rotate">
    <div class="rotate-item" data-vt="rotate" style="--hue: 30;"></div>
    <div class="rotate-item" data-vt="rotate" style="--hue: 102;"></div>
    <div class="rotate-item" data-vt="rotate" style="--hue: 174;"></div>
    <div class="rotate-item" data-vt="rotate" style="--hue: 246;"></div>
    <div class="rotate-item" data-vt="rotate" style="--hue: 318;"></div>
  </div>
  <button type="button" data-vt="rotate-btn">次へ</button>
</div>

<!-- CSS -->
<style>
@layer base, components, vt;

@layer vt {
  .rotate-item {
    view-transition-name: match-element;
  }
}

@layer components {
  .rotate {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-template-areas: 
      "first first . ."
      "first first last .";
    gap: 16px;
    inline-size: 100%;
  }
  .rotate-item:nth-child(1) {
    grid-area: first;
  }
  .rotate-item:nth-child(5) {
    grid-area: last;
  }
  .rotate-item {
    aspect-ratio: 1;
    border: solid 2px black;
    background-color: oklch(80% 0.4 var(--hue, 0));
  }
}

@layer base {
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  button {
    display: block;
    margin-inline: auto;
    padding: 8px;
    inline-size: min(90vi, 200px);
  }
  .stage {
    display: grid;
    gap: 24px;
    align-content: center;
    inline-size: min(100%, 720px);
    block-size: 100%;
    margin: auto;
    padding: 40px;
  }
}
</style>

<!-- JS -->
<script>
const rotateItems = () => {
  const update = () => {
    // 先頭のアイテムを末尾に追加
    const firstItem = document.querySelector('[data-vt="rotate"]');
    if (!firstItem) return;
    const parent = firstItem.parentElement;
    parent?.append(firstItem);
  };

  if (!document.startViewTransition) {
    update();
    return;
  }

  document.startViewTransition(() => update());
};

const btn = document.querySelector('[data-vt="rotate-btn"]');
btn?.addEventListener('click', () => rotateItems());
</script>

コード自体は非常にシンプルで、ボタンをクリックしたときに View Transition を開始し、コールバック関数で先頭のアイテムを末尾に追加しています。

JS の抜粋
const rotateItems = () => {
  const update = () => {
    // 先頭のアイテムを末尾に追加
    const firstItem = document.querySelector('[data-vt="rotate"]');
    if (!firstItem) return;
    const parent = firstItem.parentElement;
    parent?.append(firstItem);
  };

  if (!document.startViewTransition) {
    update();
    return;
  }

  document.startViewTransition(() => update());
};

あとは、対象の要素に対して view-transition-name を指定するだけです。要素は複数なので、さきほど説明した match-element キーワードを指定します。

CSS の抜粋
.rotate-item {
  view-transition-name: match-element;
}

ここでは、アニメーションについては何も指定していませんが、これだけで DOM 変更前後の状態が補間されて、自然に変化するアニメーションが実現します。もちろん、個別にアニメーションを調整することも可能です。

続いて、さきほどよりは複雑な例です。以下のデモでは、各アイテム(ボタン)を選択すると、Lightbox のように画像がポップアップされて拡大表示されます。

Live Demo
250

簡略化したコードは以下ですが、こちらもコード量が多いため折りたたんでいます。

コードを確認する
<!-- HTML -->
<div class="stage">
  <button type="button" class="graph --btn" data-dialog="btn">
    <div class="graph-shape --a" data-dialog="clone">
      <svg width="100" height="100" viewBox="0 0 100 100">
        <rect width="100" height="100" />
        <g>
          <polygon points="55,25 75,55 45,75 25,45" />
          <polygon points="55,25 75,55 45,75 25,45" />
          <polygon points="55,25 75,55 45,75 25,45" />
        </g>
      </svg>
    </div>
    <p class="graph-name">Lorem</p>
  </button>
  <button type="button" class="graph --btn" data-dialog="btn">
    <div class="graph-shape --b" data-dialog="clone">
      <svg width="100" height="100" viewBox="0 0 100 100">
        <rect width="100" height="100" />
        <g>
          <polygon points="55,25 75,35 55,75 25,70" />
          <polygon points="55,25 75,35 55,75 25,70" />
          <polygon points="55,25 75,35 55,75 25,70" />
        </g>
      </svg>
    </div>
    <p class="graph-name">Ipsum</p>
  </button>
</div>

<dialog class="dialog" data-dialog="container">
  <div class="dialog-inner" data-dialog="vt">
    <div class="graph" data-dialog="target"></div>
    <button type="button" class="dialog-close" data-dialog="close">
      <svg width="20" height="20" viewBox="0 0 20 20">
        <title>Close</title>
        <line x1="0" y1="0" x2="20" y2="20" stroke="currentColor" stroke-width="2"></line>
        <line x1="20" y1="0" x2="0" y2="20" stroke="currentColor" stroke-width="2"></line>
      </svg>
    </button>
  </div>
  <div class="dialog-bg" data-dialog="bg"></div>
</dialog>

<!-- CSS -->
<style>
@layer base, components;

@layer components.graph {
  .graph {
    display: block;
    inline-size: 100%;
    block-size: fit-content;
    padding-block-end: 0;
    border: solid 2px black;
    background-color: white;
  }
  .graph.--btn {
    appearance: none;
    cursor: pointer;
    transition: scale 0.2s ease;
  }
  .graph.--btn:hover,
  .graph.--btn:focus-visible {
    scale: 1.05;
  }
  .graph.--btn:active {
    scale: 1;
  }
  .graph-shape :is(svg, img) {
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }
  .graph-shape.--a {
    --color: #1b6ef3;
  }
  .graph-shape.--b {
    --color: #1ea446;
  }
  .graph-shape rect {
    fill: color-mix(in srgb, var(--color) 20%, white);
  }
  .graph-shape polygon {
    fill: var(--color);
    transform-origin: center;
  }
  .graph-shape polygon:nth-child(2) {
    mix-blend-mode: difference;
  }
  .graph-shape.--a polygon:nth-child(2) {
    scale: -1 1;
  }
  .graph-shape.--b polygon:nth-child(2) {
    scale: -1 -1;
  }
  .graph-shape polygon:nth-child(3) {
    rotate: 15deg;
    scale: 50%;
    mix-blend-mode: difference;
  }
  .graph-name {
    padding-block: 10px;
    font-size: calc(1rem * 18 / 16);
  }
}

@layer components.dialog {
  .dialog {
    inline-size: 100%;
    max-inline-size: calc(720px + 40px * 2);
    margin: auto;
    padding-block: 80px;
    padding-inline: 40px;
    border: none;
    background: none;
  }
  .dialog::backdrop {
    background-color: teal;
    mix-blend-mode: multiply;
  }
  .dialog-inner {
    margin: auto;
    position: relative;
    z-index: 1;
    inline-size: 100%;
  }
  .dialog .graph {
    border: none;
  }
  .dialog-close {
    position: absolute;
    inset-block-start: -60px;
    inset-inline-end: 0;
    inline-size: 40px;
    aspect-ratio: 1;
    border: solid 2px black;
    background-color: white;
    cursor: pointer;
  }
  .dialog-bg {
    position: fixed;
    inset: 0;
  }
}

@layer base {
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  .stage {
    display: grid;
    grid-template-columns:repeat(auto-fit, minmax(15em, 1fr));
    align-content: center;
    gap: 24px;
    inline-size: min(100%, 720px);
    block-size: 100%;
    margin: auto;
    padding: 40px;
  }
}
</style>

<!-- JS -->
<script>
const dialog    = document.querySelector('[data-dialog="container"]');
const dialogVt  = dialog?.querySelector('[data-dialog="vt"]');
const target    = dialog?.querySelector('[data-dialog="target"]');
const bg        = dialog?.querySelector('[data-dialog="bg"]');
const closeBtn  = dialog?.querySelector('[data-dialog="close"]');
const popupBtns = document.querySelectorAll('[data-dialog="btn"]');
let currentElem = null;

const toggleModal = async (open, oldElem, newElem) => {
  if (!oldElem || !newElem) return;

  // コールバック関数
  const update = () => {
    if (!dialog) return;

    if (open) {
      if (!target || !oldElem) return;
      target.textContent = '';
      target.append(oldElem.cloneNode(true));
      dialog.showModal();
    } else {
      dialog.close();
    }
  };

  if (!document.startViewTransition) {
    update();
    return;
  }

  try {
    oldElem.style.setProperty('view-transition-name', 'modal');

    // View Transition 開始
    const transition = document.startViewTransition(() => {
      oldElem.style.removeProperty('view-transition-name');
      newElem.style.setProperty('view-transition-name', 'modal');
      update();
    });

    await transition.finished;
  } finally {
    oldElem.style.removeProperty('view-transition-name');
    newElem.style.removeProperty('view-transition-name');
  }
};

popupBtns.forEach((btn) => {
  btn.addEventListener('click', () => {
    const oldElem = btn.querySelector('[data-dialog="clone"]');
    currentElem = oldElem;
    toggleModal(true, oldElem, dialogVt);
  });
});

closeBtn?.addEventListener('click', () => toggleModal(false, dialogVt, currentElem));
bg?.addEventListener('click', () => toggleModal(false, dialogVt, currentElem));
</script>

まず、ポップアップの仕組みとしては、ボタンをクリックしたときに <dialog> 要素を開いているのですが、そのときにボタンの直下にある <svg> 要素を複製して、<dialog> 要素内に追加しています。

このとき、JS で動的に view-transition-name プロパティを付け外しすることによって、DOM 変更前後の状態のキャプチャを実現しています。

以下に JS のコードを抜粋しますが、ポイントとしては、View Transition のライフサイクルの各タイミングで、view-transition-name を付け替えていることです。

JS の抜粋
const toggleModal = async (open, oldElem, newElem) => {
  // ...

  try {
    oldElem.style.setProperty('view-transition-name', 'modal');

    // View Transition 開始
    const transition = document.startViewTransition(() => {
      oldElem.style.removeProperty('view-transition-name');
      newElem.style.setProperty('view-transition-name', 'modal');
      update();
    });

    await transition.finished;
  } finally {
    oldElem.style.removeProperty('view-transition-name');
    newElem.style.removeProperty('view-transition-name');
  }
};

関数の引数として渡している oldElemnewElem は、DOM 変更の前後の要素です。

まず、oldElem に対して view-transition-name: modal を指定することで、変更前のキャプチャの対象としています。

次に、コールバック関数が呼び出される前に、view-transition-name: modaloldElem から newElem に付け替えることで、newElem を変更後のキャプチャの対象としています。

このことにより、異なる要素間での View Transition が実現します。

そして、viewTransition.finished が呼び出されたタイミングで、view-transition-name 属性を削除しています。


ちなみに、ポップアップのデモでは、速度を落とすと顕著なのですが、拡大縮小時のアニメーションには、改善の余地があります。また、今回はデモ用にシンプルな構成にしていますが、実現したいアニメーションと構成要素の組み合わせによっては、実装が一気に複雑になることがあります。

とはいえ、従来の実装方法と比較すると、位置やサイズに細かく配慮することなく、これだけのコード量でモーフィングアニメーションが実現することには、目を見張るものがあります。

View Transition のインタラクティブ性

見出し「View Transition のインタラクティブ性」

View Transition の擬似要素が存在する間は、マウスのようなポインティングデバイスや、スマートフォンのようなタッチデバイスによるインタラクティブな操作が阻止されます。しかし、キーボードによる操作は阻止されません。

以下のデモでは、アニメーションの速度を遅くしています。アニメーションが終わるまでの間に、再びボタンをクリックしたり、レンジスライダーを操作してみてください。

Live Demo
100
5000

ポインティングデバイスやタッチデバイスでは、View Transition の最中には、周囲のリンクなども含めて、インタラクティブな要素が操作できないことがわかります。

この問題を解消するためには、まずは、UA スタイルシートで定義されているルート要素の View Transition を無効にします。加えて、::view-transition 擬似要素に pointer-events: none を指定します。

インタラクティブ性を確保するための CSS
:root {
  view-transition-name: none;

  &::view-transition {
    pointer-events: none;
  }
}

以下は、この CSS を反映したデモです。

Live Demo
100
5000

周囲の要素が操作できるようになりましたが、View Transition の最中に新たな View Transition を開始すると、アニメーションは中断されます。

このように、いわば限られた回避策であるため、そもそも View Transition において、長時間のアニメーションは避けたほうがよいでしょう。

なお、この記事では深掘りしませんが、速度の設定を遅くして、View Transition のアニメーション中にスクロールすると気づく現象があります。

対象要素のインタラクティブ性

見出し「対象要素のインタラクティブ性」

ここまでの例を踏まえて、View Transition の対象となる要素内のインタラクティブ性について見ていきます。以下は、カラー変更の要素にテキストエリア(<textarea>)を含めたデモです。

Live Demo
5000

View Transition の最中には、ポインティングデバイスやタッチデバイスでテキストエリアにフォーカスしたり、入力することはできません。しかし、キーボードではそれらの操作が可能です。

たとえば、キーボードで「カラー変更」ボタンを EnterSpace で選択して、その直後に Shift + Tab でテキストエリアにフォーカスして、入力することができます。

View Transition の最中は、すべてのデバイスで操作できないというのなら理解できますが、キーボードでは操作できてしまうのは、少し腑に落ちないところです。

先ほどの、インタラクティブ性を確保するための CSS はグローバルスコープであるため、ほかのスタイルと競合する可能性があります。

インタラクティブ性を確保するための CSS
/* グローバルスコープであるため競合する可能性がある */
:root {
  view-transition-name: none;

  &::view-transition {
    pointer-events: none;
  }
}

この問題への対応としては、「複数の View Transition」のセクションで説明したコードを応用して、一時的にスコープを作成する方法が考えられます。

一時的に CSS のスコープを作成する例
/* `types` を使用する方法 */
:root:active-view-transition-type(color) {
  view-transition-name: none;

  &::view-transition {
    pointer-events: none;
  }
}

/* カスタムデータ属性(`data-*`)を使用する方法 */
:root[data-vt-type="color"] {
  view-transition-name: none;

  &::view-transition {
    pointer-events: none;
  }
}

この記事では、View Transitions のアニメーションのカスタマイズ方法や、気をつけるポイントを実例とともに紹介しました。

View Transitions は、従来の要素に対してアニメーションを指定する方法から、擬似要素に対してアニメーションを指定する方法に、考え方を切り替える必要があります。今までとは少し異なる乗り物なので、コツをつかむまでに慣れを要します。

また、複数の View Transition が競合してしまう場合や、ブラウザごとの挙動の違い3、アニメーション中のインタラクティブ性などの制約によって、思ったように実装できずに行き詰まってしまうこともあるかもしれません。

今回、実際にいくつかのデモを作成することで、View Transitions に対する理解は深まりました。ただ、紹介しきれなかった例もあり、まだまだアイデア次第で可能性は広がるので、引き続き有用な使い道を探っていきたいです。

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. WICG によって、Scoped View Transitions も提案されており、Chrome 140 からフラグを有効にすることで使用できますが、まだ試験的な段階(Experimental)であり、Baseline に達するまでには時間がかかりそうです。

  2. ちなみに、このページ内には複数の View Transition のデモが存在していますが、このカスタムデータ属性(data-*)を使用する方法で、それぞれのデモが競合しないように調整しています。

  3. この記事では、ブラウザの差異については取り上げませんでしたが、Firefox では position: sticky を指定した要素が存在すると、View Transition の最中に、ちらつきが発生する現象が見受けられました。