🎬

Headless UI + Tailwind CSS で Twitter 風のドロワーを作ってみた

2021/09/03に公開

Headless UI + Tailwind CSS を組み合わせてドロワーを楽に実装できたので紹介します。

以下のような Twitter っぽいのドロワーを作成しました。

https://twitter.com/heavenOSK/status/1433431433146605571?s=20

Headless UI とは

Headless UI はスタイルを排除したコンポーネント集で、Tailwind CSS と相性がいいとのことです。

https://headlessui.dev/

スタイルを排除した設計

各コンポーネントのスタイルを切り替えるタイミングを抽象化してくれていて、タイミングごとに適用したいスタイルを prop で渡す設計になっています。

以下のようなイメージです。

<SomeHeadlessUIComponent
   tapDown="tapDownStyleClass" // <= タップ開始時のスタイル
   tapUp="tapUpStyleClass" // <= タップ終了時のスタイル
/>

このような設計になっているため、クラスを使用して Utillity-First でスタイルを適用する Tailwind CSS と相性がいいと紹介されているのだと思います。

実装方法

ドロワーの実装方法を紹介します。

package のインストール

Headless UI

npm install @headlessui/react
# or
yarn add @headlessui/react

(参考: tailwindlabs/headlessui | Github)

Tailwind CSS

以下のドキュメントに従ってインストールします。

https://tailwindcss.com/docs/installation

今回使用するコンポーネント

DialogTransition というコンポーネントを使用します。

Dialog

https://headlessui.dev/react/dialog
オーバーレイを表示するためのコンポーネントで、 open という props に boolean の値を渡すと portal を作成/破棄してくれます。
また、Dialog.Overlay を使用して「コンテンツの枠外をタップするとドロワが閉じる」挙動を実装することができます。

<Dialog 
  open={isOpen} 
  onClose={() => setIsOpen(false)}>
 <Dialog.Overlay />  // Overlay をタップすると祖先の `Dialog` の onClose が呼び出される
</Dialog>

Transition

https://headlessui.dev/react/transition
アニメーションを開始・終了を管理するためのコンポーネントで、
表示(enter,enterFrom,enterTo)・非表示(leave,leaveFrom,leaveTo) 時のスタイルを指定できるようになっています。

<Transition
    show={isShowing}
    enter="transition-opacity ease-in-out duration-250"
    enterFrom="opacity-0"
    enterTo="opacity-100"
    leave="transition-opacity ease-in-out duration-250"
    leaveFrom="opacity-100"
    leaveTo="opacity-0"
>
I will fade in and out
</Transition>

複数のアニメーションを同期

Transition.Child というコンポーネントを Transition の子として配置すると、表示状態を同期することができます。
これによって同じタイミングで複数のアニメーションを動かすことができます。

<Transition show={isOpen}>
    <Transition.Child
        enter="animation-pattern-01"
        // ...
    >
      Content-01
    </Transition.Child>
    <Transition.Child
        enter="animation-pattern-02"
        // ...
    >
      Content-02
    </Transition.Child>
</Transition>

Twitter 風のドロワー

ドロワーでは以下の二つのレイヤが必要です。

  • Drawer レイヤ
    • コンテンツが表示される場所
  • Overlay レイヤ
    • ドロワの下に隠れる半透明のレイヤ

各レイヤでは表示・非表示時に異なるアニメーションをさせたいため、Transition.Child を二つ並べます。

Drawer レイヤ

左から引き出されるようなアニメーションさせたいため、開閉時にtranslate-x-0から-translate-x-fullまで変化するようにします。

<Transition.Child
    className="absolute inset-0 z-40 flex pointer-events-none"
    enter="transition ease duration-250 transform"
    enterFrom="-translate-x-full"
    enterTo="translate-x-0"
    leave="transition ease duration-250 transform"
    leaveFrom="translate-x-0"
    leaveTo="-translate-x-full"
>
    {children}
</Transition.Child>

Overlay レイヤ

画面全体の透明度が変化して、徐々に実体が見えてくるようなアニメーションをさせたいので、開閉時に opacity-0からopacity-100まで変化するようにしています。

<Transition.Child
    enter="transition-opacity ease-in-out duration-250"
    enterFrom="opacity-0"
    enterTo="opacity-100"
    leave="transition-opacity ease-in-out duration-250"
    leaveFrom="opacity-100"
    leaveTo="opacity-0"
>
    <Dialog.Overlay
        className="z-30 bg-gray-500 absolute inset-0 backdrop-filter bg-opacity-40"
    />
</Transition.Child>

上記の二つを組み合わせると以下のようになります。

import React, {Dispatch, SetStateAction} from 'react'
import {Dialog, Transition} from '@headlessui/react'

interface Props {
    isOpen: boolean
    setIsOpen: Dispatch<SetStateAction<boolean>>
}

const TwitterDrawerTransition = ({isOpen, setIsOpen, children}: React.PropsWithChildren<Props>) => {
    return (
        <Transition show={isOpen}>
            <Dialog
                className="fixed inset-0 z-40 overflow-hidden lg:hidden"
                onClose={() => setIsOpen(false)}
            >
            <Transition.Child
                enter="transition-opacity ease-in-out duration-250"
                enterFrom="opacity-0"
                enterTo="opacity-100"
                leave="transition-opacity ease-in-out duration-250"
                leaveFrom="opacity-100"
                leaveTo="opacity-0"
            >
                <Dialog.Overlay
                    className="z-30 bg-gray-500 absolute inset-0 backdrop-filter bg-opacity-40"
                />
            </Transition.Child>
                <Transition.Child
                    className="absolute inset-0 z-40 flex pointer-events-none"
                    enter="transition ease duration-250 transform"
                    enterFrom="-translate-x-full"
                    enterTo="translate-x-0"
                    leave="transition ease duration-250 transform"
                    leaveFrom="translate-x-0"
                    leaveTo="-translate-x-full"
                >
                    {children}
                </Transition.Child>
            </Dialog>
        </Transition>
    )
}

export default TwitterDrawerTransition

使用する側

Recoil を使って状態の更新をドロワーコンポーネント内で閉じるようにしました。

TwitterDrawer

import TwitterDrawerTransition from "./TwitterDrawerTransition";
import {useRecoilState} from "recoil";
import isDrawerOpenState from './isDrawerOpenState';


const TwitterDrawer = () => {
    const [isDrawerOpen, setIsDrawerOpen] = useRecoilState(isDrawerOpenState)
    return (
        <TwitterDrawerTransition
            isOpen={isDrawerOpen}
            setIsOpen={setIsDrawerOpen}
        >
            <div className="bg-white flex-1 max-w-xs min-w-0 border-r border-opacity-10">
                <div className="flex h-14 px-3 items-center border-b border-twitter-border-color">
                    <h2 className="text-lg font-bold flex-grow">
                        アカウント情報
                    </h2>
                </div>
            </div>
            // ..
        </TwitterDrawerTransition>
    )
}

export default TwitterDrawer

以下のように、ページのどこかにドロワを配置する。

import TwitterHeader from "../components/twitter/TwitterHeader";
import {useSetRecoilState} from "recoil";
import isDrawerOpenState from "../components/twitter/TwitterDrawer/isDrawerOpenState";
import TwitterDrawer from "../components/twitter/TwitterDrawer/TwitterDrawer";


const TwitterHome = () => {
    const setIsDrawerOpen = useSetRecoilState(isDrawerOpenState)
    return <>
        <main className="text-black">
            <TwitterHeader
                title="最新のツイート"
                onThumbnailClick={() => {
                    setIsDrawerOpen(true)
                }}
            />
            <TwitterDrawer />
        </main>
    </>
}
export default TwitterHome

おわりに

Headless UI は他にも便利なコンポーネントがありますし、Headless UI + Tailwind CSS の組み合わせはかなり楽だったので是非使ってみてください。

Discussion