Webアプリケーションでは、スタイルが変わらずに異なる振る舞いを持つコンポーネントがよく見られます。
例えば、ボタンには基本的に<button>
要素を利用してonClick
を使用しますが、<a>
要素としてhref
も持つことがあります。
その振る舞いがどうあれ、ボタンコンポーネントはデザインシステムのスタイルを保持すべきです。ここで「ポリモーフィックコンポーネント」と呼ばれるものが登場します。
この記事では、同じスタイルを保ちつつ振る舞いを変えることができるポリモーフィックボタンの作成方法について解説します。
目的
目的は単純で、html
要素を変更できるボタンコンポーネントを持つことです。
例えば、ボタンは<a>
リンクや<button>
ボタンのように振る舞うことができ、したがってそのルート要素の属性を持つことができます。
import React from 'react'
import { Button } from '@/ui/button'
export const Home = () => {
return (
<>
<Button as={'a'} href={'/test'}>
内部 リンク
</Button>
<Button
as={'a'}
href={'https://www.example.com'}
rel="noreferrer"
target="_blank"
>
外部 リンク
</Button>
<Button>ボタン</Button>
</>
)
}
似たようなスタイルのコンポーネントがありますが、DOMを調べてみると、ボタンコンポーネントのルートにある要素はまったく異なっています。
利点
ポリモーフィックコンポーネントを作成することには3つの利点があります。
-
HTML構造の一貫性:
<button>
と<a>
要素が互いに入れ子にならないので、クローラーはWebアプリケーションの構造を正しく解釈します -
柔軟性:
<button>
と<a>
要素に加えて、コンポーネントは、例えばrole="button"
を持つdiv
のような、どんな要素でも取ることができます -
堅牢性:
Typescript
のおかげで、入力された要素に応じて、IDE(例: VSCode)
は期待される属性を正しく解釈します(<a>
にはhref
がありますが、<button>
にはないため異常性を検知してくれます)
1. ボタンコンポーネントの作成
最初のステップは、下記のように作成することです。
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>
export const Button = ({ ...props }: Props) => {
return <button {...props} />
}
ここで、ボタンコンポーネントは、HTML属性のProps
に似た属性を期待します。
これは動的ではなく、ルート要素は<button>
でなければなりません。
2. ボタンのスタイルを追加
次のステップは、デザインシステムに合わせてボタンにスタイルを追加することです。
TailwindCSS
とtailwind-merge
で、Class Variance Authority (CVA)
を使用します。
デザインシステムとClass Variance Authority (CVA)
については、下記の参考文献を参照してください。
参考文献
- Class Variance Authority
- Next.jsでTailwindを使ったデザインシステムの構築をしよう!
- Vue × Tailwind CSS × CVA × tailwind-mergeによるコンポーネント実装
- Class Variance Authority(CVA) で Tailwind CSS の className を管理する
import React from 'react'
import { twMerge } from 'tailwind-merge'
import { cva, type VariantProps } from 'class-variance-authority'
// スタイルを追加
const button = cva(['font-medium', 'py-2.5', 'px-3.5', 'rounded-md'], {
variants: {
intent: {
primary: ['bg-blue-500', 'text-white'],
secondary: ['bg-black', 'text-white'],
ghost: ['text-blue-500'],
},
},
defaultVariants: {
intent: 'primary',
},
})
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof button>
export const Button = ({ className, intent, ...props }: Props) => (
<button className={twMerge(button({ className, intent }))} {...props} />
)
これで、ボタンコンポーネントはProps
でインテントを受け取ることができるようになりました。あとはボタンのポリモーフィックな側面を管理するだけです。
3. as属性を変更して要素をパラメータとして受け取る
ここで、コンポーネントのルートで使用したい要素を指定できるようにする必要があります。
慣例では、as={要素(エレメント)}
属性を使用します。
したがって、特定のHTML要素にデフォルトで存在するas
を削除し、Elementタイプを待つ必要があります。
import React from 'react'
import { twMerge } from 'tailwind-merge'
import { cva, type VariantProps } from 'class-variance-authority'
// スタイルを追加
const button = cva(['font-medium', 'py-2.5', 'px-3.5', 'rounded-md'], {
variants: {
intent: {
primary: ['bg-blue-500', 'text-white'],
secondary: ['bg-black', 'text-white'],
ghost: ['text-blue-500'],
},
},
defaultVariants: {
intent: 'primary',
},
})
// デフォルトを削除
type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'as'> & {
as?: Element
} & VariantProps<typeof button>
export const Button = ({ className, intent, ...props }: Props) => (
<button className={twMerge(button({ className, intent }))} {...props} />
)
4. 動的型の使用
最後に、ルート要素を動的に変更し、各要素に期待される型を高度に解釈することです。
デフォルトでは、私たちのコンポーネントはボタンなので、コンポーネントがインスタンス化されるときに as
が指定されなければ、この要素を保持します。
import React from 'react'
import { twMerge } from 'tailwind-merge'
import { cva, type VariantProps } from 'class-variance-authority'
// デフォルトを削除することにより、要素に応じて動的に型を取得
type PolymorphicProps<Element extends React.ElementType, Props> = Props &
Omit<React.ComponentProps<Element>, 'as'> & {
as?: Element
}
// スタイルを追加
const button = cva(['font-medium', 'py-2.5', 'px-3.5', 'rounded-md'], {
variants: {
intent: {
primary: ['bg-blue-500', 'text-white'],
secondary: ['bg-black', 'text-white'],
ghost: ['text-blue-500'],
},
},
defaultVariants: {
intent: 'primary',
},
})
// バリアント...異なるデータ型を単一の型として扱うことができるデータ構造
// Class Variance Authority (CVA)を利用してバリアントを取得する方法
type Props = VariantProps<typeof button>
// asが指定されていない場合のデフォルト要素を定義
const defaultElement = 'button'
// 適切に型付けされ、デザインされたButtonコンポーネント
export const Button = <
Element extends React.ElementType = typeof defaultElement
>(
props: PolymorphicProps<Element, Props>
) => {
const { as: Component = defaultElement, className, intent, ...rest } = props
return (
<Component className={twMerge(button({ className, intent }))} {...rest} />
)
}
まとめ
TailwindCSS
を使用して、React(Typescript)
でポリモーフィックコンポーネントなボタンを作成することは、スタイルの一貫性とHTML要素の選択の柔軟性を維持するための、綺麗な解決を提供します。
このアプローチにより、HTML構造はクリーンでクローラーに理解しやすいままとなり、アクセシビリティと UI/UX
が向上します。
さらに、Typescript
を使用することで、React
でTypescript
を使用する多くの利点の恩恵を受けることができます。
ポリモーフィックなボタンとClass Variance Authority (CVA)
によるスタイリングアプローチの組み合わせにより、保守性や再利用性が高く、柔軟で、拡張性が高いコンポーネントといえます。