Capro Web
← すべてのコンテンツに戻る

タブ

投稿日

タブのアクティブインジケーターにアニメーションを実装するにはいろいろな方法があり得ます。では、それぞれの方法を用いることにはどのような利点があるのでしょうか。

この記事では以下の三つの方法1で実装を行いました。

  1. リストの子を動かす
  2. タブごとの子を動かす
  3. 複製したリストをインジケーターとみなす

本記事ではそれぞれの方法につき見ていき、最後にどの方法を用いるべきか検討します。

なお文中で用いている「タブ」はUIコンポーネントとしてのタブ全体ではなく、タブ全体を構成しそれぞれのコンテンツと結び付けられた各ボタンを意味します。「リスト」はすべての「タブ」の集まりです。

リストの子を動かす

インジケーターをリストの子コンポーネントとしてレンダーし、アクティブになったタブに存在するであろうインジケーターのサイズ・位置に一致させるようにアニメーションを行います。

変更するプロパティ: width, height, transform

インジケーターの分解

インジケーターのタイプ

再生時間

イージング関数

<Root>
  <List className='relative'>
    <Tab>Chrome</Tab>
    <Tab>Edge</Tab>
    <Indicator
      className='absolute inset-0 size-0 bg-black'
      style={{
        height: '3px',
        width: '100px',
        transform: 'translateX(100px)',
      }}
    />
  </List>
</Root>

アニメーションの見た目通りに素直に実装したものです。上記コード例からわかるようにシンプルなHTML構造が特徴です。

注意する点としては、インジケーターの絶対位置とアニメーションの移動量の間で同期が必要であり、壊れやすいまたは絶対位置を取得する分のコード量が増えるということです。(たとえば、インジケーターの絶対位置指定がright: 0;のみであるにもかかわらずアニメーションの側ではleft: 0;であることを前提にtranslateX()を使用する場合を考えよ。)

タブごとの子を動かす

インジケーターをそれぞれのタブの子コンポーネントしてレンダーし、以前アクティブであったインジケーターのサイズ・位置から自身のサイズ・位置へ戻ってくるようにアニメーションを行います。

変更するプロパティ: transform

インジケーターの分解

インジケーターのタイプ

再生時間

イージング関数

<Root>
  <List>
    <Tab className='relative'>
      <Indicator className='absolute inset-x-0 bottom-0 h-px w-full bg-black' />
      Chrome
    </Tab>
    <Tab className='relative'>
      <Indicator className='absolute inset-x-0 bottom-0 h-px w-full bg-black' />
      Edge
    </Tab>
  </List>
</Root>

アニメーションにはアクティブ状態を切り替える二つのインジケーターの相対的なサイズ・位置関係のみが関心の対象となります。つまりインジケーターがアニメーション完了後にどのようなサイズ・位置にあるか、スタイルがどうなっているかはブラウザ(HTMLやCSS)が計算してくれます。これは二つのメリットを提供します。

複製したリストをインジケーターとみなす

複製したリストをインジケーターとみなし、クリッピング領域を変更することでアクティブになったタブが持っているであろうインジケーターのサイズ・位置に一致させるようにアニメーションを行います。

変更するプロパティ:clip-path

インジケーターの分解

インジケーターのタイプ

再生時間

イージング関数

<Root className='relative'>
  <List>
    <Tab>Chrome</Tab>
    <Tab>Edge</Tab>
  </List>
  <List
    className='absolute inset-0 bg-black'
    style={{ clipPath: 'inset(0 50% 0 0)' }}
  >
    <Tab>Chrome</Tab>
    <Tab>Edge</Tab>
  </List>
</Root>

上記デモにおいてインジケーターのタイプが下線またはバックグラウンドの場合、リストの子を動かす方法と動作の方針として変わらないように見えます。

しかし、この方法が強力なのは1. インジケーターがタブのラベルの背景である2. タブのラベルがインジケーターの位置に応じてcolorを変更するという二つの要件を満たす(上記デモにおけるテキスト反転をインジケーターのタイプに持つ)場合です。この場合インジケーターの移動に応じてラベルが色を変えるアニメーションを正確に実現可能です。なお他の方法でもインジケーターの構成次第でmix-blend-modeで代替できるようです。

まとめ

さて以上を踏まえてどの方法を用いるべきでしょうか。パフォーマンス、可能なアニメーションという観点から整理してみましょう。

パフォーマンス

いずれの方法でもCSSトランジションウェブアニメーション APIを用いてアニメーションを実現できます。

では変更するプロパティはどうでしょうか。CSS 座標変換(transformのみを使用するタブごとの子を動かす方法はハードウェアアクセラレーションを最大限利用できます。3clip-pathハードウェアアクセラレーションを利用できるため複製したリストをインジケーターとみなす方法も同様に有効です。

一方でDOMサイズを減らすという観点に立てば、リストの子を動かす方法が最もDOMツリーを小さく保てるのに対して、複製したリストをインジケーターとみなす方法は無駄が大きくなるということになります。4

もっともこれらは一般論であり実際の開発に応じて計測・比較されるべき問題です。

可能なアニメーション

複製したリストをインジケーターとみなす方法が一定のパターンに対して機能することは説明したとおりです。一方でそれ以外のパターンの場合、いずれの方法でもほとんど実現可能なはずです。

Appendix

上記以外で実装のときに考えたことをまとめています。

アニメーションのタイミング

再生時間は200ms、イージング関数はease-outにしました。

再生時間は、アニメーションが大きくないこと、スクリーン上の出入りを伴うものでないことから150〜350ms程度が妥当と思われます。たとえばMaterial Webでは250ms、Vercelでは150msです。またStripe Blogでは350msでアニメーションを強調しています。ただし速すぎる値は、移動するタブ間の距離が大きいと不自然に感じられます。

ユーザーは自分の選択(クリックやキーボード入力)に対して速やかに応答が得られることを期待するはずです。そのためにはアクティブインジケーターが速やかに動き始めるease-outが最適です。たとえばMaterial Webではcubic-bezier(0.3,0,0,1)、Vercelも同様に初めが速く終わりに緩やかなグラフを描きます。

Vercelにおけるタブアニメーションのタイミング

React Spectrumではease-in-outを用いているようです。私のタブで試した感想としては100msから150ms程度でease-in-outを用いる場合、再生時間の短さを感じさせません。一方でそれ以上の再生時間の場合、ラグが生じているような違和感があります。

React Spectrumにおけるタブアニメーションのタイミング

重ね合わせの順序

タブおよびインジケーターがシンプルに構成・スタイリングされる場合は問題ないのですが、インジケーターをラベル幅に合わせるなどタブの子要素を入れ子にしていくと意図しない要素の重なり合いが生じやすいです。

重ね合わせコンテキストがどの要素で作成されているのか、その要素の位置指定の有無・出現順z-indexの数値をもとに順位決定を行います。不要な位置指定は取り除く(position: static;に保つ)ことで順位の決定がわかりやすくなります。

またz-indexを指定することでタブ以外の要素と干渉しやすくなるため、タブの境界部分(Radix UIにおける<Tabs.Root><Tabs.List>など)でisolation: isolate;を用いて重ね合わせコンテキストを独立させておくと良いです。

アクセシビリティ

ベースライブラリには@radix-ui/react-tabsを用いてアクセシビリティを確保しています。

複製したリストをインジケーターとみなす方法では、同じタブが存在することに注意します。アニメーションの役割をもつ側のタブではaria-hidden="true"かつtabindex="-1"に指定し支援技術からコンテンツを隠すようにします。

Footnotes

  1. 実装と記事作成にあたりMaterial WebPaco CourseyEmil Kowalskiに大きく触発されています。なお取り上げられませんでしたがReact Spectrumは、この記事とは異なる実装を行っており興味深いです。

  2. motionでは、ある新しい要素がDOMに入るとき、共通するlayoutIdをもつ要素の位置関係をもとにアニメーションを行います。この実装よりも高度で機能的ですがベースは共通しています。こちらのデモはmotionを用いてアクティブインジケーターのアニメーションを実現しています。

  3. ちなみにリストの子を動かす方法でもwidthheightを変更するのではなくtransform: scale();に置き換え可能かもしれません。しかし私の技術ではborder-radiusの反映が難しかったため置換していません。

  4. ただしこの記事内のデモではSSRに対応するべくいずれの方法でもタブごとの子を動かす方法プラスαの実装をしているので正確には構成や要件次第ということになります。