HextaUI

Button group

Des boutons réunis en un seul contrôle, avec jointures partagées, séparateurs, addons de texte, imbrication et piles verticales.

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

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

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 assemble les boutons que vous utilisez déjà. Installez aussi <Button />.

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

Tailles

Définissez size sur chaque bouton. Les coins joints restent carrés à toutes les tailles, et les coins extérieurs gardent le rayon du bouton.

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>
  )
}

Bouton scindé

Les boutons pleins sont collés : ajoutez un <ButtonGroupSeparator /> entre eux. Un déclencheur de menu déroulant peut être n’importe quel segment.

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>
  )
}

Séparateurs

Séparateurs entre des boutons-icônes, avec un bouton à bascule qui indique son état pressé.

"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>
  )
}

Retour d’état dans un groupe

Lorsque la largeur du libellé d’un bouton change pendant le chargement, le succès ou l’erreur, les segments suivants glissent au lieu de sauter.

"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>
  )
}

Vertical

orientation="vertical" empile les segments. Les séparateurs passent automatiquement à l’horizontale.

"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>
  )
}

Groupes imbriqués

Les groupes à l’intérieur d’un groupe deviennent des ensembles distincts séparés par un écart, chacun étant joint séparément.

"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>
  )
}

Avec du texte et un champ

<ButtonGroupText /> ajoute une étiquette ou un préfixe. Rendez-le comme un <label> pour nommer le champ. Le champ s’étire pour remplir la ligne.

"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>
  )
}

Désactivé et invalide

Désactivez ou marquez un seul segment avec aria-invalid. Un segment invalide passe au-dessus de ses voisins pour que son anneau rouge ne soit jamais recouvert.

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>
  )
}

Pleine largeur

Donnez une largeur au groupe et flex-1 à chaque bouton pour répartir l’espace uniformément.

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>
  )
}

Comme fieldset

Utilisez render pour produire un <fieldset> dans les formulaires.

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>
  )
}

De droite à gauche

Les coins joints, les séparateurs et les ensembles imbriqués s’inversent dans les mises en page de droite à gauche.

"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>
  )
}
ToucheAction
TabParcourt les segments dans l’ordre. Le segment ayant le focus passe au-dessus pour que son anneau et sa bordure ne soient jamais recouverts.
EnterSpaceActive le bouton ayant le focus.
  • Le groupe a role="group". Donnez-lui un aria-label qui décrit l’ensemble, comme « Message actions ».
  • Les segments réduits à une icône ont besoin de leur propre aria-label.
  • Les séparateurs sont exposés comme séparateurs avec la bonne orientation. Ajoutez decorative lorsque la ligne est purement visuelle.

Chaque partie rend un <div> et accepte ses attributs.

PropTypePar défaut
orientation
"horizontal" | "vertical""horizontal"
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="button-group"Ciblez les groupes en CSS.
data-orientationL’orientation actuelle.
PropTypePar défaut
orientationPar défaut, l’opposé de l’orientation du groupe.
"horizontal" | "vertical"–
decorativeMasque la ligne aux lecteurs d’écran.
booleanfalse
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="button-group-separator"Ciblez les séparateurs en CSS.
data-orientationL’orientation résolue.
PropTypePar défaut
renderRendez un <label> pour nommer un champ du groupe.
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="button-group-text"Ciblez les segments de texte en CSS.