Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般
  • Ruby / Rails関連

Railsの技: TailwindスタイルのCSSトランジションをStimulusJSで行う(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Railsの技: TailwindスタイルのCSSトランジションをStimulusJSで行う(翻訳)

本記事は、Boring Railsの新シーズンコンテンツ「Hotwire Summer」の一部です。

StimulusJSでUI要素をビルドしたことがあるなら、きっとページ上の要素の表示・非表示を切り替えるコードを書いたことがあるでしょう。これまでの私たちは、モーダルダイアログを表示するときやパネルをスライドさせるときに以下のようなコントローラコードをひとつ覚えで書いていたものです。

this.modalTarget.classList.remove("hidden")

UIデザインをレベルアップさせる目的で、トランジション(transition)1で画面上の要素の表示・非表示を滑らかに切り替える方法が使えます。トランジションを使うと、不透明な要素をじわじわ透明にしたり、要素を表示位置にスライドインしたりできるようになります。

CSSのtransitionプロパティでこうしたアニメーションを行おうとするときの問題のひとつは、トランジションが発生する前に要素のdisplayプロパティを変更できないことです(Sebastian De Deyneの良記事"Enter & leave transitions"でも指摘されています)。

transitionの基本的なスタイルは、ボタンにマウスオーバーすると色を変えるといったささいな変更には使えますが、要素の表示・非表示切り替えをスムーズにアニメーション化する場合はそれだけでは足りません。

VueAlpineのコミュニティで醸成されたパターンに、一連のdata-*属性を用いてトランジションのライフサイクル中に欲しいCSSクラスを定義するという方法があります。TailwindUIも、コンポーネントのアニメーション方法を指定するときにこのパターンに沿っています。このパターンは強力かつ理解しやすいことが明らかになっています。

プロジェクトには固有の命名規則がありますが、いずれも基本的なライフサイクルの6段階の「ステージ」を定義しています。

Entering("Entering Active"、"Enter Active"、"Enter"などとも呼ばれる)
ページにenterしている間、要素に存在すべきクラス
Enter From("Enter Start"とも呼ばれる)
ページにenterする時点でのトランジション開始点
Enter To("Enter End"とも呼ばれる)
ページにenterする時点でのトランジション終了点
Leaving("Leaving Active"、"Leave Active"、"Leave"などとも呼ばれる)
ページからleaveしている間、要素に存在すべきクラス
Leave From("Leave Start"とも呼ばれる)
ページからleaveする時点でのトランジション開始点
Leave To("Leave End"とも呼ばれる)
ページからleaveする時点でのトランジション終了点

Vueのドキュメントにあるこの図は、この仕組みを理解するのにとても有用でした。

The lifecycle stages for CSS transitions in Vue

data-*属性を使う方法は、Tailwindのトランジションユーティリティクラスとの相性が良く、StimulusJSがHTMLマークアップを拡張するときの哲学ともマッチしていると思えます。

VueのTransitionやAlpineのx-transitionは、フレームワークでこうしたトランジションをネイティブでサポートしていますが、Hotwireを用いるRailsアプリではトランジションを自分で追加する必要があります。

オプション

この分野でいくつかの選択肢を調べてみました。以下は、自分が出会った中で最も一般的なアプローチです。

1. stimulus-transitions

robbevp/stimulus-transition - GitHub

stimulus-transitionライブラリは、インポート・登録可能なtransitionコントローラを提供します。このコントローラは、アプリケーション内で通常のStimulusコントローラと同様に使えます。

<div data-controller="transition"
     data-transition-enter-active="enter-class"
     data-transition-enter-from="enter-from-class"
     data-transition-enter-to="enter-to-class"
     data-transition-leave-active="or-use multiple classes"
     data-transition-leave-from="or-use multiple classes"
     data-transition-leave-to="or-use multiple classes">
  <!-- コンテンツ -->
</div>

このコントローラは、自動的に要素の表示・非表示を検出してトランジションを実行します。トランジションの完了後に別のコードも実行したい場合はカスタムのtransition:end-entertransition:end-leaveイベントをリッスンすることも可能です。

transitionは要素のスタイルを表示するために何らかのトリガーを必要とするので、処理を開始するにはアプリケーションレベルのコントローラが必要になります。

2. stimulus-use/useTransition

stimulus-use/stimulus-use - GitHub

stimulus-useプロジェクトは、Stimulus向けの再利用可能な振る舞いのコレクションです。Reactに慣れている人には、このプロジェクトがReactのhooksシステムと似ているように思えますが、Stimulusコントローラ向けである点が異なります。

ミックスイン可能なuseTransitionは、特別なパッケージのひとつです。これをStimulusコントローラから呼び出すと、その要素でトランジションを実行します。要素はdata-*属性から読み取ることも、オプションとしてJavaScriptでクラスを指定することも可能です。

このライブラリは、本記事執筆時点では"beta"リリースです。

3. el-transition

mmccall10/el-transition - GitHub

el-transitionはStimulus以外でも使えますが、VueやAlpineと同じトランジションパターンを実装しています。素のJavaScriptで書かれているので、Stimulusライフサイクル用のフックや登録用のコントローラは組み込まれていません。enter関数やleave関数を直接インポートし、トランジションする要素を指定して呼び出すことになります。

import {enter, leave} from 'el-transition'

// Stimulusコントローラのどこかに置く
enter(this.modalTarget)
leave(this.modalTarget)

ライブラリのコードはたった1ファイルで、しかもわずか60行なので、プロジェクトで直接ベンダリングすることも可能です。

私のおすすめ: el-transition

3つのライブラリは、私が欲しいVueやAplineスタイルのdata-*属性によるトランジションを適用する点ではどれも同じです。

この中で最もうまくいったのはel-transitionだったので、自分のプロジェクトではこれを採用しました。

el-transitionは超シンプルで、フレームワークに縛られていない点が気に入りました。インターフェイスも最小限で済み、外部ライブラリにも依存していないので、仮に破壊的変更が発生してもStimulusの新しいリリースに対応するために更新する必要もありません。

さらに嬉しいボーナスは、enter関数とleave関数がPromiseオブジェクトを返すことです。これはトランジションの必要な複数の要素を調整するのにとても有効でした(この手法はTailwind UIコンポーネントでよく使われています)。

Tailwind UIでメニューをスライドインさせる

このアドバイスを実践するために、Tailwind UIで右からスライドインするメニューを構築してみましょう。

最初に、HTMLコードテンプレートを手に入れます(本記事ではフリー素材を使っています)。

ここではslide-overコントローラを作成することにします。このコントローラのターゲットは、Tailwindマークアップ内の3つのパーツ(backdroppanelcloseButton)の他に、メニュー全体(container)も含まれます。

なお、コードにはコンポーネントのさまざまなパーツとそのトランジション方法をコメントで書いてあります。

<!--
  Background backdrop, show/hide based on slide-over state.

  Entering: "ease-in-out duration-500"
    From: "opacity-0"
    To: "opacity-100"
  Leaving: "ease-in-out duration-500"
    From: "opacity-100"
    To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"></div>

これらの各コンポーネントパーツをStimulusのターゲットとしてバインドし、仕様に合う形でdata-*属性にも追加します。

<div data-controller="slide-over">
  ...

  <div data-slide-over-target="backdrop"
      class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
      data-transition-enter="ease-in-out duration-500"
      data-transition-enter-start="opacity-0"
      data-transition-enter-end="opacity-100"
      data-transition-leave="ease-in-out duration-500"
      data-transition-leave-start="opacity-100"
      data-transition-leave-end="opacity-0"></div>

  ...
</div>

アニメーションさせたい他の要素にも同様に反映します。基本的な<button>要素も追加して、クリックするとパネルを表示するようにします。

以下はHTMLマークアップ全体です。

<div data-controller="slide-over">
  <button class="form-input" data-action="slide-over#show">Show slideover</button>

  <!-- This example requires Tailwind CSS v2.0+ -->
  <div data-slide-over-target="container" class="relative z-10 hidden" aria-labelledby="slide-over-title" role="dialog" aria-modal="true">
    <!--
      Background backdrop, show/hide based on slide-over state.

      Entering: "ease-in-out duration-500"
        From: "opacity-0"
        To: "opacity-100"
      Leaving: "ease-in-out duration-500"
        From: "opacity-100"
        To: "opacity-0"
    -->
    <div data-slide-over-target="backdrop"
      class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
      data-transition-enter="ease-in-out duration-500"
      data-transition-enter-start="opacity-0"
      data-transition-enter-end="opacity-100"
      data-transition-leave="ease-in-out duration-500"
      data-transition-leave-start="opacity-100"
      data-transition-leave-end="opacity-0"></div>

    <div class="fixed inset-0 overflow-hidden">
      <div class="absolute inset-0 overflow-hidden">
        <div class="fixed inset-y-0 right-0 flex max-w-full pl-10 pointer-events-none">
          <!--
            Slide-over panel, show/hide based on slide-over state.

            Entering: "transform transition ease-in-out duration-500 sm:duration-700"
              From: "translate-x-full"
              To: "translate-x-0"
            Leaving: "transform transition ease-in-out duration-500 sm:duration-700"
              From: "translate-x-0"
              To: "translate-x-full"
          -->
          <div data-slide-over-target="panel"
            class="relative w-screen max-w-md pointer-events-auto"
            data-transition-enter="transform transition ease-in-out duration-500 sm:duration-700"
            data-transition-enter-start="translate-x-full"
            data-transition-enter-end="translate-x-0"
            data-transition-leave="transform transition ease-in-out duration-500 sm:duration-700"
            data-transition-leave-start="translate-x-0"
            data-transition-leave-end="translate-x-full">
            <!--
              Close button, show/hide based on slide-over state.

              Entering: "ease-in-out duration-500"
                From: "opacity-0"
                To: "opacity-100"
              Leaving: "ease-in-out duration-500"
                From: "opacity-100"
                To: "opacity-0"
            -->
            <div data-slide-over-target="closeButton"
              class="sm:-ml-10 sm:pr-4 absolute top-0 left-0 flex pt-4 pr-2 -ml-8"
              data-transition-enter="ease-in-out duration-500"
              data-transition-enter-start="opacity-0"
              data-transition-enter-end="opacity-100"
              data-transition-leave="ease-in-out duration-500"
              data-transition-leave-start="opacity-100"
              data-transition-leave-end="opacity-0">
              <button type="button" data-action="slide-over#hide" class="hover:text-white focus:outline-none focus:ring-2 focus:ring-white text-gray-300 rounded-md">
                <span class="sr-only">Close panel</span>
                <!-- Heroicon name: outline/x -->
                <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            </div>

            <div class="flex flex-col h-full py-6 overflow-y-auto bg-white shadow-xl">
              <div class="sm:px-6 px-4">
                <h2 class="text-lg font-medium text-gray-900" id="slide-over-title">Panel title</h2>
              </div>
              <div class="sm:px-6 relative flex-1 px-4 mt-6">
                <!-- Replace with your content -->
                <div class="sm:px-6 absolute inset-0 px-4">
                  <div class="h-full border-2 border-gray-200 border-dashed" aria-hidden="true">
                    Your content goes here! How about a lazy-loaded turbo-frame?
                  </div>
                </div>
                <!-- /End replace -->
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

このトランジションを実際に実行するには、Stimulusコントローラの実装が必要です。

import { Controller } from "@hotwired/stimulus"
import { enter, leave } from "el-transition"

export default class extends Controller {
  static targets = ["container", "backdrop", "panel", "closeButton"]

  show() {
    this.containerTarget.classList.remove("hidden")
    enter(this.backdropTarget)
    enter(this.closeButtonTarget)
    enter(this.panelTarget)
  }

  hide() {
    Promise.all([
      leave(this.backdropTarget),
      leave(this.closeButtonTarget),
      leave(this.panelTarget)
    ]).then(() => {
      this.containerTarget.classList.add("hidden")
    })
  }
}

ボタンをクリックしてshowアクションが呼び出されると、まずコンテナ全体のhiddenクラスを削除し、続いてアニメーションしたいターゲットごとにel-transitionenter関数を実行します。これによって背景とクローズボタンがフェードインし、data-*属性で定義したTailwindクラスを用いているパネルが右からスライドインします。

クローズボタンをクリックしてhideアクションがトリガーされると、上とちょうど逆の操作を行います。leave関数を実行してパネルを左にスライドさせ、背景とクローズボタンをフェードアウトします。トランジションがすべて完了したらコンテナ全体を隠します。Promise.allを使っているので、個別のトランジションがすべて完了するまで待ってからコンテナを隠せるようになります(トランジションごとに実行時間が異なることをお忘れなく)。

トランジションが完了してから削除するときに、setTimeoutを使う必要もコンテンツを全消しする必要もありません。

Example video of StimulusJS slide-over menu from TailwindUI

Tailwind UIのReactスニペットやVueスニペットほど手軽ではないものの、かなりいい線行っています!

このHTMLマークアップをパーシャルに切り出したりViewComponentでコードを整理したりすることでさらに改善できますが、これについては各自の練習におまかせします。

まとめ

よそのフロントエンドコミュニティの知恵を自分のエコシステムに取り入れるのは素晴らしいことです。VueやAlpineでは、CSSトランジションを明確でわかりやすく指定するパターンが既に確立しているので、ちょっとしたライブラリを用いることで、そうした成果をStimulusやHotwireのプロジェクトで活用できます。

これらのトランジションを理解するには少し時間がかかるものの、最小限の努力でUIコンポーネントに洗練されたアニメーションを追加できるようになります。これはまさしく、本ブログのタイトルである「Boring Rails」スタイルでアプリを構築するのに必要な、広く活用できるテクニックです。


本記事が役に立つと思った方は、ぜひフォームでニュースレターの登録をお願いします。余分な文章を削ぎ落とした読みやすく価値ある情報だけをニュースレターで配信いたします。

関連記事

Railsの技: HotwireとRailsで使える新しいCSSトリック(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。