📝

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


これらを管理する機能を自前で用意するのは骨が折れそうなのと、スパゲティコードになりそうです。
自前でstatepropsclassNameをこねくりまわして状態を管理しないといけません。

また、個別に汎用コンポーネントに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 を使用して解決しようと思います。

https://www.tailwind-variants.org/

こちらの「ムーザルちゃんねる」で詳しく解説されておりますので、是非観て頂きたいです。
Tailwind CSSを使う時は、これでclassNameを管理していますが、かなり管理しやすいです。
外からclassNameを渡すのではなく、variantsで管理することにより、
styleのロジックをコンポーネント内で一元化出来ます。

https://www.youtube.com/watch?v=bdEmTOxsF9s

(Z).について

storybookで管理しようと思います。
ただ、プロジェクト全体のコンポーネントをstorybookで管理するのはコストや時間とのトレードオフになると思います。
私は、今回のような汎用コンポーネントだけ*.storiesファイルを用意するようにしています。

実際のコード

コードは以下になります。

⚙️ボタンコンポーネント本体

  • aタグ、Linkタグ、buttonタグを切り替えられるようにしています
  • classNameは3つのタグで使いまわせるので保守性は高いです
  • aタグやLinkタグにも擬似的にdisabledを実装しています
src/components/ui/button/index.tsx

/**
 * ボタンコンポーネント
 * 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を分ける時

に対応しています。

src/components/ui/button/button-icon.tsx
/**
 * ボタン内アイコンコンポーネント
 * 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}
        />
      )}
    </>
  )
}

⚙️ボタンのローディングスピナー

ボタンのローディング用のコンポーネントです。

src/components/ui/button/button-loading-spinner.tsx
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

これは、汎用コンポーネントには必要です。
なるべく全パターンのモックを用意した方がいいでしょう。

src/components/ui/button/button.stories.tsx

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

で管理することを推奨します。

src/components/utils/icon/index.tsx

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コンポーネントとして
持っておくと便利です。色の変更にも柔軟に対応出来るためです。

src/components/utils/icon/icon-microcms.tsx

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を使ったフロントエンド開発などやってます。
お仕事のご依頼など、はこちらから!

https://www.htmlgo.site/

Discussion