logo

GitHub風!ドロップダウン付きボタンを自作してみた【Headless UI + Tailwind CSS】

2025-05-13
2 months ago

開発環境

  • react 19.1.0
  • next 15.3.2
  • typescript 5
  • tailwindcss 3.4.1
  • @headlessui/react 2.2.2

前提

GitHubのような「左にメインのアクションボタン、右にドロップダウンアイコンが付いていて複数の操作を選べるボタン」って、UIとして便利ですよね。でも、意外とこのパターンの情報がまとまってなくて困ったので、自作してみました。

この記事では、Headless UI + Tailwind CSS を使ってドロップダウン付きボタンを実装する手順を紹介します。

本題

全体構成

このドロップダウン付きボタンは以下の3つのパーツで構成されています。

  1. UIコンポーネント群(DropdownButton 系)
  2. カスタムフック(選択状態管理用)
  3. 利用例(サンプル)

順番に紹介していきます。

1. UIコンポーネント群

まずはボタン周りの構造から。Headless UI の <Menu> コンポーネントをベースに構成しています。

ルートコンポーネント

const DropdownButtonRoot = ({ children, triggerButton, className }) => {
  return (
    <div className={cn('inline-flex', className)}>
      {triggerButton}
      <Menu as="div" className="relative">
        {children}
      </Menu>
    </div>
  );
};
  • triggerButton: 左側のデフォルトアクション(CSVダウンロードなど)
  • children: 右側のドロップダウンに関係する部分

デフォルトアクションボタン

const DropdownButtonAction = ({ onClick, children, className }) => {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn('inline-flex min-h-12 ...')}
    >
      {children}
    </button>
  );
};

普通のボタンとして扱います。クリック時の処理は onClick に渡す形。

ドロップダウントリガー

const DropdownButtonTrigger = ({ icon, className, children }) => {
  return (
    <MenuButton as={Fragment}>
      {({ active }) => (
        <button className={cn('bg-white ...', active && 'bg-gray-25', className)}>
          {icon || children}
        </button>
      )}
    </MenuButton>
  );
};
  • Headless UI の <MenuButton> を使用
  • icon は Material Icons などを渡すことを想定

ドロップダウンメニューとアイテム

const DropdownMenu = ({ children, className }) => (
  <Transition ...>
    <MenuItems className={cn('absolute right-0 ...', className)}>
      {children}
    </MenuItems>
  </Transition>
);

const DropdownMenuItem = ({ onClick, children, className }) => (
  <MenuItem>
    {({ focus }) => (
      <button className={cn('w-full ...', focus && 'bg-gray-100', className)} onClick={onClick}>
        {children}
      </button>
    )}
  </MenuItem>
);

<MenuItems><MenuItem> は Headless UI の標準仕様に沿っています。


全体の実装はこちらです。

import { Fragment, type ReactNode } from 'react';

import {
  Menu,
  MenuButton,
  MenuItem,
  MenuItems,
  Transition,
} from '@headlessui/react';

import { cn } from '@/utils/cn';

// ========== ルートコンポーネント ==========
type DropdownButtonRootProps = {
  children: ReactNode;
  triggerButton: ReactNode;
  className?: string;
};

const DropdownButtonRoot = ({
  children,
  triggerButton,
  className,
}: DropdownButtonRootProps) => {
  return (
    <div className={cn('inline-flex', className)}>
      {/* 左側:デフォルトアクション */}
      {triggerButton}
      {/* 右側:プルダウンメニュー */}
      <Menu as="div" className="relative">
        {children}
      </Menu>
    </div>
  );
};

// ========== アクションボタン ==========
type DropdownButtonActionProps = {
  onClick?: () => void;
  children: ReactNode;
  className?: string;
};

const DropdownButtonAction = ({
  onClick,
  children,
  className,
}: DropdownButtonActionProps) => {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        'inline-flex min-h-12 items-center rounded-l-lg border border-gray-200 bg-white px-4 py-2 hover:bg-gray-25 active:bg-gray-25',
        className,
      )}
    >
      {children}
    </button>
  );
};

// ========== トリガーボタン ==========
type DropdownButtonTriggerProps = {
  icon?: ReactNode;
  className?: string;
  children?: ReactNode;
};

const DropdownButtonTrigger = ({
  icon,
  className,
  children,
}: DropdownButtonTriggerProps) => {
  return (
    <MenuButton as={Fragment}>
      {({ active }) => (
        <button
          className={cn(
            'bg-white hover:bg-gray-25 -ml-px inline-flex items-center min-h-12 rounded-r-md border border-gray-200 p-3 focus:outline-none',
            active && 'bg-gray-25',
            className,
          )}
        >
          {icon || children}
        </button>
      )}
    </MenuButton>
  );
};

// ========== ドロップダウンメニュー ==========
type DropdownMenuProps = {
  children: ReactNode;
  className?: string;
};

const DropdownMenu = ({ children, className }: DropdownMenuProps) => {
  return (
    <Transition
      as={Fragment}
      enter="transition ease-out duration-100"
      enterFrom="transform opacity-0 scale-95"
      enterTo="transform opacity-100 scale-100"
      leave="transition ease-in duration-75"
      leaveFrom="transform opacity-100 scale-100"
      leaveTo="transform opacity-0 scale-95"
    >
      <MenuItems
        className={cn(
          'absolute right-0 z-10 mt-3 w-max origin-top-right rounded-lg border border-gray-200 bg-white shadow focus:outline-none',
          className,
        )}
      >
        {children}
      </MenuItems>
    </Transition>
  );
};

// ========== ドロップダウンメニューアイテム ==========
type DropdownMenuItemProps = {
  onClick?: () => void;
  children: ReactNode;
  className?: string;
};

const DropdownMenuItem = ({
  onClick,
  children,
  className,
}: DropdownMenuItemProps) => {
  return (
    <MenuItem>
      {({ focus }) => (
        <button
          className={cn(
            'flex w-full pr-3 py-3 pl-2.5 gap-1 items-center border-b border-gray-200 last:border-b-0',
            focus && 'bg-gray-100',
            className,
          )}
          onClick={onClick}
        >
          {children}
        </button>
      )}
    </MenuItem>
  );
};

export {
  DropdownButtonAction,
  DropdownButtonRoot,
  DropdownButtonTrigger,
  DropdownMenu,
  DropdownMenuItem,
};

2. ドロップダウン状態を管理するカスタムフック

複数のオプションからアクティブなものを選ぶために、簡単なフックを用意しました。

'use client';

import { useCallback, useState } from 'react';

export type DropdownOption = {
  id: string;
  label: string;
};

type UseDropdownProps = {
  options: DropdownOption[];
  defaultOption?: DropdownOption;
};

export const useDropdown = ({ options, defaultOption }: UseDropdownProps) => {
  const [activeItem, setActiveItem] = useState<DropdownOption>(
    defaultOption || options[0],
  );

  const isActive = useCallback(
    (option: DropdownOption) => {
      return activeItem.id === option.id;
    },
    [activeItem],
  );

  return {
    activeItem,
    setActiveItem,
    isActive,
  };
};
  • activeItem: 現在選択されているオプション
  • setActiveItem: 選択変更用
  • isActive: チェックマーク表示に使います

3. 利用サンプル

最後に実際にボタンを使ってみる例を紹介します。 用途としては、「CSVダウンロード」を3種類のバージョンから選べるケースを想定しています。


'use client';

import {
  DropdownButtonAction,
  DropdownButtonRoot,
  DropdownButtonTrigger,
  DropdownMenu,
  DropdownMenuItem,
} from '@/components/ui/dropdown-button';
import { Icon } from '@/components/ui/icon';
import { useDropdown } from '@/hooks/use-dropdown';

const options = [
  {
    id: '1',
    label: 'CSVダウンロード - v1',
  },
  {
    id: '2',
    label: 'CSVダウンロード - v2',
  },
  {
    id: '3',
    label: 'CSVダウンロード - v3',
  },
];

export const SampleDropdownButton = () => {
  const { activeItem, setActiveItem, isActive } = useDropdown({ options });

  return (
    <DropdownButtonRoot
      // 左側:デフォルトアクション
      triggerButton={
        <DropdownButtonAction
          onClick={async () => {
            try {
              switch (activeItem.id) {
                case '1':
                  // CSVダウンロード - v1 の処理
                  // await trigger1()
                  console.log('CSVダウンロード - v1 の処理');
                  break;
                case '2':
                  // CSVダウンロード - v2 の処理
                  // await trigger2()
                  console.log('CSVダウンロード - v2 の処理');
                  break;
                case '3':
                  // CSVダウンロード - v3 の処理
                  // await trigger3()
                  console.log('CSVダウンロード - v3 の処理');
                  break;
                default:
                  throw new Error('Invalid option selected');
              }
              console.log('ダウンロードしました!');
            } catch (error) {
              console.error(error);
            }
          }}
        >
          {activeItem.label}
        </DropdownButtonAction>
      }
    >
      {/* 右側:プルダウンメニュー  */}
      <DropdownButtonTrigger>
        <Icon icon="keyboard_arrow_down" className="size-5" />
      </DropdownButtonTrigger>
      <DropdownMenu>
        {options.map((option) => (
          <DropdownMenuItem
            key={option.id}
            onClick={() => setActiveItem(option)}
          >
            <div className="flex items-center gap-1">
              {isActive(option) ? (
                <Icon icon="check" className="size-6" />
              ) : (
                <div className="size-6" />
              )}
              {option.label}
            </div>
          </DropdownMenuItem>
        ))}
      </DropdownMenu>
    </DropdownButtonRoot>
  );
};
  • triggerButton に現在のアクションラベルを表示
  • ドロップダウンから他のオプションを選択すると、以後のアクションボタンの挙動が変わります
  • Icon コンポーネントは任意のアイコンライブラリ(Material Iconsなど)で代替可

完成イメージ

さいごに

GitHubにあるようなドロップダウン付きボタンは、Headless UI を使えば意外とスムーズに実装できます。

カスタマイズ性も高く、ユースケースに合わせて簡単に拡張可能です。

この記事が同じような UI を探している方の参考になれば幸いです。

参照