HextaUI

Button group

1つのコントロールとして連結されたボタンです。共有される継ぎ目、区切り線、テキストアドオン、入れ子、縦積みに対応します。

import {
  IconArchive,
  IconClock,
  IconDotsVertical,
  IconFlag,
} from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"

export function ButtonGroupDemo() {
  return (
    <ButtonGroup aria-label="Message actions">
      <Button variant="outline">
        <IconArchive data-icon="inline-start" />
        <span className="max-sm:sr-only">Archive</span>
      </Button>
      <Button variant="outline">
        <IconFlag data-icon="inline-start" />
        <span className="max-sm:sr-only">Report</span>
      </Button>
      <Button variant="outline">
        <IconClock data-icon="inline-start" />
        <span className="max-sm:sr-only">Snooze</span>
      </Button>
      <Button variant="outline" size="icon" aria-label="More actions">
        <IconDotsVertical />
      </Button>
    </ButtonGroup>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/button-group.json

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

import {
  ButtonGroup,
  ButtonGroupSeparator,
  ButtonGroupText,
} from "@/components/ui/button-group"
<ButtonGroup aria-label="Text formatting">
  <Button variant="outline">Bold</Button>
  <Button variant="outline">Italic</Button>
  <Button variant="outline">Underline</Button>
</ButtonGroup>

Button group は、すでに使っているボタンを連結します。<Button /> もあわせてインストールしてください。

ButtonGroup
├── Button
├── ButtonGroupSeparator
├── ButtonGroupText
└── ButtonGroup

サイズ

各ボタンに size を設定します。連結した角はどのサイズでも直角のままで、外側の角はボタンの角丸を保ちます。

import { IconBold, IconItalic, IconUnderline } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"

const sizes = ["xs", "sm", "default", "lg"] as const

export function ButtonGroupSizes() {
  return (
    <div className="flex flex-col items-center gap-3">
      {sizes.map((size) => (
        <ButtonGroup key={size} aria-label="Formatting">
          <Button variant="outline" size={size}>
            <IconBold data-icon="inline-start" />
            Bold
          </Button>
          <Button variant="outline" size={size}>
            <IconItalic data-icon="inline-start" />
            Italic
          </Button>
          <Button variant="outline" size={size}>
            <IconUnderline data-icon="inline-start" />
            Underline
          </Button>
        </ButtonGroup>
      ))}
    </div>
  )
}

スプリットボタン

塗りのボタンは隙間なく並ぶため、間に <ButtonGroupSeparator /> を追加します。ドロップダウンのトリガーは、どのセグメントにもできます。

import { IconChevronDown, IconGitBranch } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export function ButtonGroupSplit() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <ButtonGroup aria-label="Deploy">
        <Button>
          <IconGitBranch data-icon="inline-start" />
          Deploy main
        </Button>
        <ButtonGroupSeparator />
        <DropdownMenu>
          <DropdownMenuTrigger
            render={<Button size="icon" aria-label="Choose branch" />}
          >
            <IconChevronDown />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuGroup>
              <DropdownMenuItem>Deploy staging</DropdownMenuItem>
              <DropdownMenuItem>Deploy preview</DropdownMenuItem>
            </DropdownMenuGroup>
          </DropdownMenuContent>
        </DropdownMenu>
      </ButtonGroup>
      <ButtonGroup aria-label="Delete">
        <Button variant="destructive">Delete</Button>
        <ButtonGroupSeparator />
        <Button variant="destructive" size="icon" aria-label="More">
          <IconChevronDown />
        </Button>
      </ButtonGroup>
    </div>
  )
}

セパレーター

アイコンボタンの間のセパレーターと、押下状態を伝えるトグル。

"use client"

import * as React from "react"
import {
  IconPlayerPause,
  IconPlayerPlay,
  IconPlayerSkipBack,
  IconPlayerSkipForward,
} from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"

export function ButtonGroupPlayer() {
  const [playing, setPlaying] = React.useState(false)

  return (
    <ButtonGroup aria-label="Playback">
      <Button variant="secondary" size="icon" aria-label="Previous track">
        <IconPlayerSkipBack />
      </Button>
      <ButtonGroupSeparator />
      <Button
        variant="secondary"
        size="icon"
        aria-label={playing ? "Pause" : "Play"}
        aria-pressed={playing}
        onClick={() => setPlaying(!playing)}
      >
        {playing ? <IconPlayerPause /> : <IconPlayerPlay />}
      </Button>
      <ButtonGroupSeparator />
      <Button variant="secondary" size="icon" aria-label="Next track">
        <IconPlayerSkipForward />
      </Button>
    </ButtonGroup>
  )
}

グループ内のフィードバック

読み込み中、成功、エラーでボタンのラベル幅が変わると、後ろのセグメントは跳ねずにスライドして追従します。

"use client"

import * as React from "react"
import { IconChevronDown } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

async function fail(ms: number) {
  await wait(ms)
  throw new Error("Request failed")
}

export function ButtonGroupFeedback() {
  const [shouldFail, setShouldFail] = React.useState(false)

  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <ButtonGroup aria-label="Save changes">
        <Button
          feedback
          loadingLabel="Saving changes"
          successLabel="All changes saved"
          errorLabel="Couldn't save"
          onClick={() => (shouldFail ? fail(900) : wait(900))}
        >
          Save
        </Button>
        <ButtonGroupSeparator />
        <Button size="icon" aria-label="More save options">
          <IconChevronDown />
        </Button>
      </ButtonGroup>
      <label className="flex items-center gap-2 text-sm text-muted-foreground">
        <input
          type="checkbox"
          checked={shouldFail}
          onChange={(event) => setShouldFail(event.target.checked)}
        />
        Fail next save
      </label>
    </div>
  )
}

垂直

orientation="vertical" はセグメントを縦に積みます。セパレーターは自動的に水平になります。

"use client"

import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"

export function ButtonGroupVertical() {
  const [zoom, setZoom] = React.useState(100)

  return (
    <div className="flex flex-wrap items-start justify-center gap-8">
      <div className="flex items-center gap-4">
        <ButtonGroup orientation="vertical" aria-label="Zoom">
          <Button
            variant="outline"
            size="icon"
            aria-label="Zoom in"
            disabled={zoom >= 200}
            onClick={() => setZoom((value) => Math.min(200, value + 25))}
          >
            <IconPlus />
          </Button>
          <Button
            variant="outline"
            size="icon"
            aria-label="Zoom out"
            disabled={zoom <= 25}
            onClick={() => setZoom((value) => Math.max(25, value - 25))}
          >
            <IconMinus />
          </Button>
        </ButtonGroup>
        <p className="text-sm text-muted-foreground tabular-nums">{zoom}%</p>
      </div>
      <ButtonGroup orientation="vertical" aria-label="Sort">
        <Button variant="outline">Newest first</Button>
        <Button variant="outline">Oldest first</Button>
        <Button variant="outline">Most discussed</Button>
      </ButtonGroup>
      <ButtonGroup orientation="vertical" aria-label="Position">
        <Button variant="secondary">Top</Button>
        <ButtonGroupSeparator />
        <Button variant="secondary">Bottom</Button>
      </ButtonGroup>
    </div>
  )
}

ネストしたグループ

グループの中のグループは、間に余白のある別々のまとまりになり、それぞれが独自に連結されます。

"use client"

import * as React from "react"
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"

const pages = [1, 2, 3, 4]

export function ButtonGroupNested() {
  const [page, setPage] = React.useState(1)

  return (
    <ButtonGroup aria-label="Pagination">
      <ButtonGroup>
        <Button
          variant="outline"
          size="icon"
          aria-label="Previous page"
          disabled={page === 1}
          onClick={() => setPage(page - 1)}
        >
          <IconArrowLeft className="rtl:-scale-x-100" />
        </Button>
        <Button
          variant="outline"
          size="icon"
          aria-label="Next page"
          disabled={page === pages.length}
          onClick={() => setPage(page + 1)}
        >
          <IconArrowRight className="rtl:-scale-x-100" />
        </Button>
      </ButtonGroup>
      <ButtonGroup>
        {pages.map((value) => (
          <Button
            key={value}
            variant={value === page ? "secondary" : "outline"}
            size="icon"
            aria-current={value === page ? "page" : undefined}
            onClick={() => setPage(value)}
          >
            {value}
          </Button>
        ))}
      </ButtonGroup>
    </ButtonGroup>
  )
}

テキストと入力欄を含める

<ButtonGroupText /> はラベルやプレフィックスを追加します。入力欄に名前を付けるには、<label> としてレンダリングします。入力欄は行を埋めるように広がります。

"use client"

import * as React from "react"
import { IconCopy, IconLink } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"

export function ButtonGroupInput() {
  const [value, setValue] = React.useState("hextaui.com/docs/button-group")

  return (
    <ButtonGroup className="w-full max-w-md" aria-label="Share link">
      <ButtonGroupText render={<label htmlFor="share-url" />}>
        <IconLink />
        https://
      </ButtonGroupText>
      <input
        id="share-url"
        value={value}
        onChange={(event) => setValue(event.target.value)}
        className="h-9 rounded-md border border-input bg-background px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden dark:bg-input/30 pointer-coarse:text-touch"
      />
      <Button
        variant="outline"
        feedback
        successLabel="Copied"
        errorLabel="Blocked"
        onClick={() => navigator.clipboard.writeText(`https://${value}`)}
      >
        <IconCopy data-icon="inline-start" />
        Copy
      </Button>
    </ButtonGroup>
  )
}

無効と不正

単一のセグメントを aria-invalid で無効にしたり、マークしたりできます。無効なセグメントは隣より前面に出るため、赤いリングが隠れることはありません。

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"

export function ButtonGroupDisabled() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <ButtonGroup aria-label="Post actions">
        <Button variant="outline">Edit</Button>
        <Button variant="outline" disabled>
          Publish
        </Button>
        <Button variant="outline">Share</Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Date range">
        <Button variant="outline">Start</Button>
        <Button variant="outline" aria-invalid>
          Missing date
        </Button>
        <Button variant="outline">End</Button>
      </ButtonGroup>
    </div>
  )
}

全幅

グループに幅を指定し、各ボタンに flex-1 を付けると、スペースを均等に分けられます。

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"

export function ButtonGroupFullWidth() {
  return (
    <ButtonGroup className="w-full max-w-md" aria-label="Range">
      <Button variant="outline" className="flex-1">
        Day
      </Button>
      <Button variant="outline" className="flex-1">
        Week
      </Button>
      <Button variant="outline" className="flex-1">
        Month
      </Button>
    </ButtonGroup>
  )
}

fieldset として使う

フォーム内で <fieldset> を出力するには render を使います。

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"

export function ButtonGroupFieldset() {
  return (
    <ButtonGroup render={<fieldset />} aria-label="Reply">
      <Button variant="outline">Reply</Button>
      <Button variant="outline">Reply all</Button>
      <Button variant="outline">Forward</Button>
    </ButtonGroup>
  )
}

右から左

連結した角、セパレーター、ネストしたまとまりは、右から左のレイアウトで反転します。

"use client"

import * as React from "react"
import {
  IconArchive,
  IconArrowLeft,
  IconArrowRight,
  IconChevronDown,
  IconDotsVertical,
} from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"

const pages = [1, 2, 3]

export function ButtonGroupRtl() {
  const [page, setPage] = React.useState(1)

  return (
    <div dir="rtl" className="flex flex-col items-center gap-3">
      <ButtonGroup aria-label="إجراءات">
        <Button variant="outline">
          <IconArchive data-icon="inline-start" />
          أرشفة
        </Button>
        <Button variant="outline">إبلاغ</Button>
        <Button variant="outline" size="icon" aria-label="المزيد">
          <IconDotsVertical />
        </Button>
      </ButtonGroup>
      <ButtonGroup aria-label="نشر">
        <Button>نشر الآن</Button>
        <ButtonGroupSeparator />
        <Button size="icon" aria-label="خيارات">
          <IconChevronDown />
        </Button>
      </ButtonGroup>
      <ButtonGroup aria-label="الصفحات">
        <ButtonGroup>
          <Button
            variant="outline"
            size="icon"
            aria-label="السابق"
            disabled={page === 1}
            onClick={() => setPage(page - 1)}
          >
            <IconArrowLeft className="rtl:-scale-x-100" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            aria-label="التالي"
            disabled={page === pages.length}
            onClick={() => setPage(page + 1)}
          >
            <IconArrowRight className="rtl:-scale-x-100" />
          </Button>
        </ButtonGroup>
        <ButtonGroup>
          {pages.map((value) => (
            <Button
              key={value}
              variant={value === page ? "secondary" : "outline"}
              size="icon"
              aria-current={value === page ? "page" : undefined}
              onClick={() => setPage(value)}
            >
              {value}
            </Button>
          ))}
        </ButtonGroup>
      </ButtonGroup>
    </div>
  )
}
キーアクション
Tabセグメントを順に移動します。フォーカス中のセグメントは前面に出るため、リングと枠線が隠れることはありません。
EnterSpaceフォーカス中のボタンを実行します。
  • グループには role="group" が付きます。「Message actions」のように、セットを説明する aria-label を付けてください。
  • アイコンのみのセグメントには、それぞれ aria-label が必要です。
  • セパレーターは、正しい向きを持つ separator として公開されます。線が見た目だけの場合は decorative を追加します。

各パーツは <div> をレンダリングし、その属性を受け付けます。

プロパティ型デフォルト
orientation
"horizontal" | "vertical""horizontal"
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="button-group"CSSでグループを指定します。
data-orientation現在の向き。
プロパティ型デフォルト
orientationデフォルトは、グループの向きと反対になります。
"horizontal" | "vertical"–
decorative線をスクリーンリーダーから隠します。
booleanfalse
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="button-group-separator"CSS でセパレーターを指定します。
data-orientation解決された向き。
プロパティ型デフォルト
render<label> をレンダリングすると、グループ内の入力欄に名前を付けられます。
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="button-group-text"CSS でテキストのセグメントを指定します。