Reactで汎用性の高いButtonコンポーネントを作る
はじめに
ButtonコンポーネントはWebサイトやWebアプリを作る時は、必ず用意しますよね。
こういうやつです。
このコンポーネントは、奥が深くて見た目は割とシンプルなのですが、
サイトのどこでも出てきて、色々なパターンが存在するので、
毎回「この場合はどうやって実装しよう、、?」
と悩むことが多いコンポーネントの1つです。
- 1つのコンポーネントで全て賄おうとして地獄になる。
- 都度ボタンコンポーネントを作ってしまい、管理が大変、煩雑になる。
- ボタンの状態が沢山ありすぎて、cssをどうやって管理すればいいのか。
今回は色々な悩みを解決出来るような汎用性の高いボタンコンポーネントの設計を考えていこうと思います。
悩み
(1).見た目は同じでも機能が違うものがある
まず最も遭遇する問題、
見た目は同じだが、以下のように機能が違う場合があります。
(1). <button>
タグ
(2). Next.jsの、<Link>
タグ
(3). 外部リンク用の <a>
タグ
本来であれば、リンクとボタンで体裁をわけるのが正解だと思いますが、
デザイナーに「それぞれ区別してデザインしてください」というのは無理に近いです。
そもそも、受託案件などでは「MTGを数回する」くらいしか時間がないのと、
デザインシステムに関心があり、そのお願いが出来るデザイナーは限られてくるので、
現実的ではありません。
ファイルはそれぞれ分けるべきか、1つに分けるべきか、悩みます。
(2).見た目のパターンが多い
ボタンコンポーネントはサイト全体で使われるので、とにかく色々なパターンが存在します。
- テキスト
- テキスト + サイズ大
- テキスト + 背景色A
- テキスト + 背景色B
- テキスト + loading
- テキスト + disabled
- テキスト + 背景色C + hover:背景色D
- テキスト + アイコンA + hover:アイコン色A
- テキスト + アイコンB + hover:アイコンB
・
・
・
これらを管理する機能を自前で用意するのは骨が折れそうなのと、スパゲティコードになりそうです。
自前でstate
とprops
とclassName
をこねくりまわして状態を管理しないといけません。
また、個別に汎用コンポーネントにclassName
を渡すと地獄になります。
それぞれの部分で使われている状態を把握できなくなり、
元のコンポーネントを編集出来なくなりますのでアンチパターンです。
<Button className='bg-blue-700 font-medium'>ボタンA</Button>
<Button className='min-w-[140px] text-sm'>ボタンB</Button>
<Button className='text-center hover:bg-base/80'>ボタンC</Button>
設計を考える
ファイルを3つに分ける場合のメリデメ
- ⭕️ 意図しないデグレが起こりづらい(複雑性が減る)
- ⭕️ ファイルの可読性が上がる
- ❌ 3ファイルになるので、更新コストが増える
- ❌ 3ファイルになるので、更新が漏れる可能性が増える
ファイルが1つの場合のメリデメ
- ❌ 意図しないデグレが起こりやすい(複雑性が増える)
- ❌ ファイルの可読性が下がる
- ⭕️ 1ファイルになるので、更新コストが下がる
- ⭕️ 1ファイルになるので、更新が漏れる可能性が減る
このコンポーネントの特性
- サイト全体で使用するコンポーネント → 検証するためのツールが必要
- 色とアイコンの種類は増えていきそう → styleを管理するための機能が必要
- タグは、
button
,a
,Link
の3つより多くはならない → 1つで管理した方が良さそう - それぞれのタグでスタイルは全く同じ → ファイルを分けるのは冗長?
- 機能自体はシンプルで、リンクを飛ばすか、onClickでhandlerを実行する → 1ファイルでも修正でデグレする可能性は低そう
結論
- (X).ファイルは分割するより1ファイルにまとめたメリットが今回は高そう
- (Y).色とアイコンなどのパターンがかなり増えそうなので、styleを管理するツールも必要
- (Z).ただ、全体で使用するので、検証するためのツールはきちんと入れたい
という結論に至りました。
(X).について
コンポーネントの特性
でトータルに判断しました。
ファイルを3つに分けて、それプラスcss管理用のファイルも必要になり、
それよりは1ファイル内でまかなった方が「今回に関しては」良さそうです。
(Y).について
Tailwind Variants
を使用して解決しようと思います。
こちらの「ムーザルちゃんねる」で詳しく解説されておりますので、是非観て頂きたいです。
Tailwind CSS
を使う時は、これでclassName
を管理していますが、かなり管理しやすいです。
外からclassName
を渡すのではなく、variants
で管理することにより、
style
のロジックをコンポーネント内で一元化出来ます。
(Z).について
storybook
で管理しようと思います。
ただ、プロジェクト全体のコンポーネントをstorybook
で管理するのはコストや時間とのトレードオフになると思います。
私は、今回のような汎用コンポーネント
だけ*.stories
ファイルを用意するようにしています。
実際のコード
コードは以下になります。
⚙️ボタンコンポーネント本体
- aタグ、Linkタグ、buttonタグを切り替えられるようにしています
-
className
は3つのタグで使いまわせるので保守性は高いです - aタグやLinkタグにも擬似的に
disabled
を実装しています
/**
* ボタンコンポーネント
* aタグ、Linkタグ、button要素でレンダリング出来る
*/
'use client'
import type { ComponentProps } from 'react'
import { tv, type VariantProps } from 'tailwind-variants'
import Link from 'next/link'
// components
import { ButtonIcon } from './button-icon'
import { type ColorVariants, type IconName } from '@/components/utils/icon'
import { ButtonLoadingSpinner } from './button-loading-spinner'
export const buttonVariants = tv({
base: [
'group inline-flex items-center justify-center gap-2',
'font-medium text-center',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', // キーボードフォーカス時のみ
'rounded-md',
'cursor-pointer',
],
variants: {
variant: {
// ボタンの種類(default, primary)
default: [
'bg-base text-white',
'hover:bg-base/80',
'focus-visible:ring-base/50', // default用のfocus-ring色
'disabled:bg-gray-400', // button要素のdisabled時
'data-[disabled=true]:bg-gray-400', // a要素のdisabled時
],
primary: [
'bg-blue-700 text-white',
'hover:bg-blue-700/80',
'focus-visible:ring-blue-300', // primary用のfocus-ring色
'disabled:bg-gray-400', // button要素のdisabled時
'data-[disabled=true]:bg-gray-400', // a要素のdisabled時
],
},
size: {
// ボタンのサイズ(md, lg)
md: 'text-sm px-4 py-2.5 min-w-[120px]',
lg: 'text-base px-6 py-3 min-w-[140px]',
},
loading: {
// ローディング時はクリックイベントを無効化し、focus-ringを削除
true: 'pointer-events-none focus-visible:ring-0',
},
disabled: {
// 無効時はクリックイベントを無効化し、focus-ringを削除
true: 'pointer-events-none focus-visible:ring-0',
},
},
defaultVariants: {
// デフォルトのバリアント
variant: 'default',
size: 'md',
disabled: false,
},
})
// 共通プロパティ
type CommonVariantProps = VariantProps<typeof buttonVariants> & {
icon?: IconName
iconSize?: number
iconColor?: ColorVariants
hoverIcon?: IconName
hoverIconColor?: ColorVariants
}
// button要素用プロパティ
// classNameはtailwind-variantsで管理するので渡さない
type ButtonElementProps = Omit<ComponentProps<'button'>, 'className'> & {
component?: 'button'
loading?: boolean
} & CommonVariantProps
// a要素用プロパティ
// classNameはtailwind-variantsで管理するので渡さない
type AnchorElementProps = Omit<ComponentProps<'a'>, 'className'> & {
component: 'a'
disabled?: boolean
} & CommonVariantProps
// Next.js Link用プロパティ
// classNameはtailwind-variantsで管理するので渡さない
type LinkElementProps = Omit<ComponentProps<typeof Link>, 'className'> & {
component: 'link'
disabled?: boolean
} & CommonVariantProps
// エクスポート用の統合型
export type Props = ButtonElementProps | AnchorElementProps | LinkElementProps
// ボタンコンポーネント
export const Button = (props: Props) => {
const {
component = 'button',
variant,
size,
icon,
iconColor,
iconSize,
hoverIcon,
hoverIconColor,
disabled,
children,
} = props
// a要素でレンダリング(外部リンク)
// aタグだけどdisabledも出来るようにする
if (component === 'a') {
const { ...rest } = props as AnchorElementProps
const className = buttonVariants({ variant, size, disabled })
return (
<a
{...rest}
className={className}
target='_blank' // 外部リンクなので_blankを指定
rel='noopener noreferrer' // 外部リンクなのでnoopener noreferrerを指定
data-disabled={disabled} // a要素のdisabled時のstyleを当てるのに必要
>
{children}
{icon && (
<ButtonIcon
icon={icon}
iconColor={iconColor}
iconSize={iconSize}
hoverIcon={hoverIcon}
hoverIconColor={hoverIconColor}
/>
)}
</a>
)
}
// Next.js Linkでレンダリング(内部リンク)
// Linkタグだけどdisabledも出来るようにする
if (component === 'link') {
const { prefetch = false, ...rest } = props as LinkElementProps
const className = buttonVariants({ variant, size, disabled })
return (
<Link
{...rest}
className={className}
prefetch={prefetch} // Link要素のprefetchを指定
data-disabled={disabled} // Link要素のdisabled時のstyleを当てるのに必要
>
{children}
{icon && (
<ButtonIcon
icon={icon}
iconSize={iconSize}
iconColor={iconColor}
hoverIcon={hoverIcon}
hoverIconColor={hoverIconColor}
/>
)}
</Link>
)
}
// button要素でレンダリング(デフォルト)
// buttonのみloadingも出来るようにする
const { loading, ...rest } = props as ButtonElementProps
const className = buttonVariants({ variant, size, disabled, loading })
return (
<button {...rest} className={className} disabled={disabled || loading}>
{children}
{loading && <ButtonLoadingSpinner />}
{!loading && icon && (
<ButtonIcon
icon={icon}
iconSize={iconSize}
iconColor={iconColor}
hoverIcon={hoverIcon}
hoverIconColor={hoverIconColor}
/>
)}
</button>
)
}
⚙️ボタン内で表示するアイコン用のコンポーネント
ボタン内にアイコンを表示したい時があるのでそれ用の子コンポーネントです。
- アイコンない時
- アイコンのホバーがない時
- アイコンとホバーアイコンの色を分ける時
- アイコンとホバーアイコンのsvgを分ける時
に対応しています。
/**
* ボタン内アイコンコンポーネント
* hover時の簡単なアイコン切り替えに対応
*/
import { Icon, type IconName, type ColorVariants } from '@/components/utils/icon'
export const ButtonIcon = ({
icon,
iconSize,
iconColor,
hoverIcon,
hoverIconColor,
}: {
icon: IconName
iconSize?: number
iconColor?: ColorVariants
hoverIcon?: IconName
hoverIconColor?: ColorVariants
}) => {
// 通常時のアイコン(hover時に隠す)
const IconComponent = Icon[icon]
// hover時のアイコン
// svgを変更出来る / svgの色を変更出来る
const HoverIconComponent = hoverIcon ? Icon[hoverIcon] : hoverIconColor ? IconComponent : null
return (
<>
{/* 通常時のアイコン(hover時に隠す) */}
{/* ホバーアイコンがある時だけ、hover時に隠す */}
<IconComponent
className={HoverIconComponent ? 'group-hover:hidden' : ''}
size={iconSize}
color={iconColor}
/>
{/* ホバーアイコン(hover時に表示) */}
{HoverIconComponent && (
<HoverIconComponent
className='hidden group-hover:inline'
size={iconSize}
color={hoverIconColor}
/>
)}
</>
)
}
⚙️ボタンのローディングスピナー
ボタンのローディング用のコンポーネントです。
export const ButtonLoadingSpinner = () => (
<svg className={`h-4 w-4 animate-spin`} fill='none' viewBox='0 0 24 24'>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
/>
<path
className='opacity-75'
fill='currentColor'
d='m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
/>
</svg>
)
⚙️ボタンコンポーネントのstorybook
これは、汎用コンポーネントには必要です。
なるべく全パターンのモックを用意した方がいいでしょう。
import type { Meta } from '@storybook/nextjs-vite'
import { fn } from 'storybook/test'
import { Button } from './'
const meta = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'フォーム送信やクリックイベント用のボタンコンポーネント。React 19対応版。',
},
},
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['default', 'primary'],
description: 'ボタンの色バリアント',
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'ボタンのサイズ(アイコンサイズも連動)',
},
loading: {
control: 'boolean',
description: 'ローディング状態(自動でスピナー表示)',
},
disabled: {
control: 'boolean',
description: '無効状態',
},
},
args: {
onClick: fn(),
children: 'ボタン',
},
} satisfies Meta<typeof Button>
export default meta
// ボタン
export const _Button = {
args: {
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
},
}
// ボタン(無効)
export const _ButtonBig = {
args: {
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
size: 'lg',
},
}
// ボタン(ローディング)
export const _ButtonLoading = {
args: {
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
loading: true,
},
}
// ボタン(アイコン)
export const _ButtonWithIcon = {
args: {
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
icon: 'home',
},
}
// ボタン(アイコンをhoverで切り替え)
export const _ButtonWithIcon2 = {
args: {
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
icon: 'home',
hoverIcon: 'github',
iconSize: 20,
},
}
// ボタン(アイコンをhoverで別の色)
export const _ButtonWithIcon3 = {
args: {
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
icon: 'home',
iconSize: 20,
hoverIconColor: 'red',
},
}
// ボタン(別の色)
export const _ButtonPrimary = {
args: {
variant: 'primary',
children: 'ボタン',
onClick: () => window.alert('ボタンがクリックされました'),
},
}
// ボタン(別の色,無効)
export const _ButtonPrimaryDisabled = {
args: {
variant: 'primary',
children: 'ボタン',
disabled: true,
onClick: () => window.alert('ボタンがクリックされました'),
},
}
// Next.js リンク
export const NextLink = {
args: {
children: 'Next.js リンク',
component: 'link',
disabled: false,
href: '/?hoge=1',
},
}
// Next.js リンク(無効)
export const NextLinkDisabled = {
args: {
children: 'Next.js リンク (無効)',
component: 'link',
disabled: true,
href: '/?hoge=1',
},
}
// 外部リンク
export const ExternalLink = {
args: {
children: '外部リンク',
component: 'a',
href: 'https://www.google.com',
},
}
// 外部リンク(無効)
export const ExternalLinkDisabled = {
args: {
children: '外部リンク (無効)',
component: 'a',
disabled: true,
href: 'https://www.google.com',
},
}
⚙️アイコンを管理するコンポーネント
サイトで管理するsvgアイコンもこちらで一元管理しましょう。
必ずこのラッパーを通すことで、
- ライブラリの変更にも柔軟に対応出来る
- どのアイコンがあるのかが一目瞭然
で、便利です。
アイコンの色も管理してあるものだけを使用する
ようにしましょう。
こちらも全パターンStorybook
で管理することを推奨します。
import React from 'react'
import { VscGithub } from 'react-icons/vsc'
import { SiNextdotjs, SiTailwindcss, SiTypescript, SiVercel } from 'react-icons/si'
import { FaReact } from 'react-icons/fa'
import { RxHome } from 'react-icons/rx'
import { FaWordpress } from 'react-icons/fa'
import { IconMicrocms } from './icon-microcms'
import { IconRecapcha } from './icon-recapcha'
/**
* アイコンの色
*/
export const colorVariants = {
white: '#fff',
red: '#ef4444',
}
/**
* アイコンの色の型定義
*/
export type ColorVariants = keyof typeof colorVariants
/**
* ローカルSVGアイコンのプロパティ型定義
*/
export type IconProps = {
className?: string
size?: number
alt?: string
color?: ColorVariants
}
/**
* アイコンの色を設定
*/
const setColor = (color?: ColorVariants, defaultColor?: ColorVariants) => {
if (defaultColor) {
// 未入力の場合はデフォルトの色を設定したい場合に使用
return colorVariants[color ?? defaultColor]
}
// 未入力の場合はデフォルトの色を設定しない
return color ?? undefined
}
/**
* アイコン管理
*/
export const Icon = {
react: ({ size = 24, color, ...props }: IconProps) => (
<FaReact {...props} size={size} color={setColor(color)} />
),
typescript: ({ size = 24, color, ...props }: IconProps) => (
<SiTypescript {...props} size={size} color={setColor(color)} />
),
nextjs: ({ size = 24, color, ...props }: IconProps) => (
<SiNextdotjs {...props} size={size} color={setColor(color)} />
),
vercel: ({ size = 24, color, ...props }: IconProps) => (
<SiVercel {...props} size={size} color={setColor(color)} />
),
tailwindcss: ({ size = 24, color, ...props }: IconProps) => (
<SiTailwindcss {...props} size={size} color={setColor(color)} />
),
github: ({ size = 24, color, ...props }: IconProps) => (
<VscGithub {...props} size={size} color={setColor(color)} />
),
wordpress: ({ size = 24, color, ...props }: IconProps) => (
<FaWordpress {...props} size={size} color={setColor(color)} />
),
home: ({ size = 18, color, ...props }: IconProps) => (
<RxHome {...props} size={size} color={setColor(color, 'white')} />
),
microcms: () => <IconMicrocms />,
recaptcha: () => <IconRecapcha />,
}
/**
* 型定義
*/
export type IconName = keyof typeof Icon
⚙️ライブラリで管理できないイレギュラーなsvgコンポーネント
今回、ライブラリで賄いきれなかった、svgアイコンはreactコンポーネントとして
持っておくと便利です。色の変更にも柔軟に対応出来るためです。
export const IconMicrocms = () => {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80.48 80.48' width='26' height='26'>
<path
fill='#fff'
d='M70.48,0a10,10,0,0,1,10,10V70a10,10,0,0,1-10,10C-15.07,78.56,1.94,95.47.48,10h0c0-5,5-10,10-10ZM65.23,36.46a5,5,0,0,0-7.07,0h0L39.07,55.56l-2.83-2.83-5.66,5.65c2.9,2.8,7.69,9.45,12,4.95h0L61.69,44.24l2.83,2.83,5.66-5.66ZM53.91,25.15a5,5,0,0,0-7.07,0h0L22.09,49.9l5.66,5.66L50.38,32.93l2.83,2.83,5.65-5.66ZM42.6,13.84a5,5,0,0,0-7.07,0h0L14.32,35.05a5,5,0,0,0,0,7.07h0l4.95,5,5.65-5.66-2.83-2.82,17-17,2.82,2.82,5.66-5.65Z'
/>
</svg>
)
}
(microCMSがグローバルなサービスに成長すれば、react-iconに組み込まれる日も近い?)
最後に
コンポーネント設計はやればやるほど「銀の弾丸はない」と思い知らされます。
私はいつも85点
なら良しという考えで設計しています。
もし、他にいい設計方法などありましたら、コメントいただけるとうれしいです!
宣伝
Next.jsを使ったフロントエンド開発などやってます。
お仕事のご依頼など、はこちらから!
Discussion