Polymorphic Components でコンポーネントの HTML 要素を呼び出し側で選べるようにする
はじめに
こんにちは。Belong でフロントエンドエンジニアをしている ryo です。このブログでは、デザインシステムのコンポーネント実装時に発生する課題と、それを解決出来る Polymorphic Components というデザインパターンについてまとめていきます。よろしくお願いします。
デザインシステムを実装する時の課題
デザインシステムのコンポーネントは OSS のコンポーネントライブラリに依らず、自前で再利用性の高いコンポーネントを作り、それを使い回す前提で実装を行うと思います。 この時課題になる事として、呼び出し側での自由度が高すぎると、デザインシステムが形骸化してしまい、かと言って自由度が低すぎても、使い難いため結局形骸化してしまう事が挙げられるのではないでしょうか。その中でも今回は、コンポーネントの要素の自由度に関する課題に焦点を当てて考えていきます。
要素の自由度に関する課題
例えば、Text コンポーネントという、Typography のコンポーネントを以下のように作成したとします。
export function Text({ children, ...props }) {
return <span {...props}>{children}</span>
}
これだと、コンポーネントの要素が必ずspanになります。しかし、Semantic HTML を満たすために、呼び出し側でspanではなく、p やh1を利用したいケースがある事が十分考えられます。
Text のようなケースの他にも、Stack のようなレイアウトコンポーネントでは、divで良い時もあれば、sectionやfooterのような意味のある要素を使いたい時もあると思います。
つまり、上記のコンポーネントは、Semantic HTML を満たすために呼び出し側で要素を決められない・変更出来ない事が課題になっています。
Semantic HTMLとは?
mdnのドキュメントやweb.devのドキュメントには、HTML を書く際、それが Semantic (意味的)である事は以下の点で重要だと記載されています。
- 検索エンジンを始めとした自動ツールが、ページの意味的な構造を理解出来るため、効果的に文脈を伝えられる
- スクリーンリーダーが意味的な構造を理解出来るため、ユーザーにアクセシブルな体験を届けられる
- 開発者目線で、意味的で読みやすい
上記の点で、Semantic HTML を満たす事は重要であり、コンポーネントの実装でもこの点は意識したいポイントです。
Polymorphic Components とは
このような時に、コンポーネントの要素を呼び出し側で任意の要素に置き換えられるようにするのが、 Polymorphic Components (ポリモーフィック コンポーネント) というデザインパターンです。
実装例
Text コンポーネントを Polymorphic Components な実装をすると、以下のようになります。
export function Text({ as, children, ...props }) {
const Component = as || 'span' //<= as として HTML Tag の指定を受け取り、jsx の type として利用する
return <Component {...props}>{children}</Component>
}
呼び出し側では以下のように使用できます。
<Text as="h1">ページタイトル</Text>
<Text as="time" dateTime="2026-03-22">2026年3月22日</Text>
上記の通り、as prop で HTML Tag を渡せるようにすることで、呼び出し側でコンポーネントの HTML 要素を任意のものにする事が出来ます。見ての通りですが Polymorphic Components にする事はとても簡潔に実現出来ます (本ブログでは as prop での実装で進めています。こちらに関する補足はas prop 以外での実装方法について をご参照ください)。
TypeScript で更に便利なコンポーネントにする
ただし、先程のコード例だと、以下のような課題が残っています。
- デザイントークンによる制限がない(実質無制限)
hogeのような存在しない HTML Tag もasとして受け取れてしまう
以下の点でもう少し便利にしたいです。
- コードを書いている際、デザイントークンの補完を受けられる
- 存在しないデザイントークンや HTML Tag が利用されないようにする
これを実現するために、TypeScript を活用して、ルールを満たしていない場合は静的型チェックで事前に気付けるようにした例1 が以下です。
import { cva, type VariantProps } from 'class-variance-authority'
import type { ComponentProps, ElementType, ReactNode } from 'react'
const textVariants = cva('', {
variants: {
size: {
md: 'text-base',
lg: 'text-3xl',
},
weight: {
normal: 'font-normal',
bold: 'font-bold',
},
},
defaultVariants: {
size: 'md',
weight: 'normal',
},
})
interface TextOwnProps<T extends ElementType = 'span'>
extends VariantProps<typeof textVariants> {
as?: T
children: ReactNode
}
type TextProps<T extends ElementType = 'span'> = TextOwnProps<T> &
Omit<ComponentProps<T>, keyof TextOwnProps<T> | 'className'>
export function Text<T extends ElementType = 'span'>({
as,
size,
weight,
children,
...props
}: TextProps<T>) {
const Component = as || 'span'
return (
<Component className={textVariants({ size, weight })} {...props}>
{children}
</Component>
)
}
使用例
上記のコンポーネントは以下のように使用出来ます。
OK
<Text as="h1" size="lg" weight="bold">ページタイトル</Text>
// => <h1 class="text-3xl font-bold">ページタイトル</h1>
<Text as="p">本文テキスト</Text>
// => <p class="text-base font-normal">本文テキスト</p>
<Text as="time" dateTime="2026-03-22">2026年3月22日</Text>
// => <time class="text-base font-normal" datetime="2026-03-22">2026年3月22日</time>
NG
<Text as="hoge">ページタイトル</Text>
// => ElementType で type check error
<Text as="p" size="sm">本文テキスト</Text>
// => size は sm を持たない為 type check error
<Text as="h1" dateTime="2026-03-22">2026年3月22日</Text>
// => h1 は dateTime 属性を持たないため type check error
かなり便利になったのではないでしょうか?更に、例えば Stack では span は利用出来ないようにするといった制限の追加も TypeScript で行う事が可能です。
まとめ
今回はデザインシステムのコンポーネント実装における課題と、Polymorphic Components について整理しました。もしこのブログを読んで、弊社で働く事に興味を持っていただけましたら エンジニアリングチーム紹介ページ をご覧いただけると幸いです。最後までお読みいただき、ありがとうございました。
参考資料
- https://web.dev/learn/html/semantic-html
- https://developer.mozilla.org/ja/docs/Glossary/Semantics
- https://developer.mozilla.org/ja/docs/Learn_web_development/Core/Accessibility/HTML
- https://www.components.build/polymorphism
- https://every-layout.dev/
- https://github.com/shadcn-ui/ui
- https://ja.react.dev/reference/react/createElement#createelement
補足: as prop 以外での実装方法について
本ブログでは as prop での polymorphic components の実装例を記載しました。その他にも、Radix UI による asChild パターン や Base UI が利用している render prop パターンによる実装方法もあります。特に as と asChild の実装は多くの議論や意見があります。
- https://www.components.build/polymorphism#comparison-as-vs-aschild
- https://zenn.dev/tsuboi/articles/8abddb1ae3038f#%E5%9E%8B%E5%AE%89%E5%85%A8%E6%80%A7%E3%81%AE%E6%AC%A0%E5%A6%82
- https://yuheiy.com/2023-06-03-react-changeable-element-type-patterns
それぞれ得手・不得手があり、どれが一番優れているという解を簡潔に出す事は出来ないと思いますが、私は as prop の実装がシンプルで、専用ライブラリへの依存が必要なく、型安全に実装出来る点が気に入っています。この辺りはコードベースの状況や、そのチームの意思決定によって選択が必要なところだと思います。
Footnotes
-
例で利用している class-variance-authority は CSS クラスによるバリアントの管理を型安全に行えるライブラリです。Polymorphic Components の実現には必須ではありませんが、デザインシステムの文脈で、バリアントのロジックを自前で用意する必要がなくなるのでお勧めです。 ↩