Headless UI + Tailwind CSS で Twitter 風のドロワーを作ってみた
Headless UI + Tailwind CSS を組み合わせてドロワーを楽に実装できたので紹介します。
以下のような Twitter っぽいのドロワーを作成しました。
Headless UI とは
Headless UI はスタイルを排除したコンポーネント集で、Tailwind CSS と相性がいいとのことです。
スタイルを排除した設計
各コンポーネントのスタイルを切り替えるタイミングを抽象化してくれていて、タイミングごとに適用したいスタイルを 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
以下のドキュメントに従ってインストールします。
今回使用するコンポーネント
Dialog
と Transition
というコンポーネントを使用します。
Dialog
また、Dialog.Overlay
を使用して「コンテンツの枠外をタップするとドロワが閉じる」挙動を実装することができます。
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}>
<Dialog.Overlay /> // Overlay をタップすると祖先の `Dialog` の onClose が呼び出される
</Dialog>
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