Перейти к содержимому

Группа кнопок

Fuses объединяет кнопки, панели инструментов и надстройки для ввода данных в единый блок с рамкой — с одной толстой рамкой, одной чёткой тенью и одним общим нажатием.

"use client"

import * as React from "react"

Группа кнопок сплавляет соседние кнопки, поля ввода и подписи в один сегментированный элемент управления. Примитива под ней нет: обычный контейнер с role="group" владеет всей необруталистской оправой — одна толстая рамка, одна жёсткая тень, одна общая анимация нажатия; к примитиву обращается только ButtonGroupSeparator, оборачивая Separator.

Когда это пригодится:

  • Составные кнопки — основное действие плюс DropdownMenu с альтернативами: Сохранить / Сохранить как…, Merge / Squash.
  • Пагинация и панели инструментов — «Назад» и «Вперёд», увеличение и уменьшение масштаба, переключатели вида, собранные в одну оправу.
  • Дополнения к полю ввода — поле поиска с кнопкой отправки, подпись с префиксом URL, кнопка копирования в конце.

Установка

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/button-group.json
npx shadcn@latest add https://neobrutalism.com/r/base/button-group.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/button-group.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/button-group.json

Установите следующие зависимости:

pnpm add @base-ui/react
npm install @base-ui/react
yarn add @base-ui/react
bun add @base-ui/react

Скопируйте и вставьте следующий код в свой проект.

components/ui/button-group.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"

const buttonGroupVariants = cva(
  cn(
    // The GROUP itself is the cohesive NeoBrutalist unit: a single bold border +
    // one hard offset shadow wrapping every segment, so it reads as ONE unit.
    // The frame animates so the whole group can press like a single Button.
    "relative inline-flex w-fit items-stretch border-2 border-foreground bg-background shadow-md transition-all duration-200",
    // Segments become flush fills — the frame owns the border, shadow and corners.
    "[&>*]:rounded-none",
    "[&>:is(button,a)]:border-0 [&>:is(button,a)]:shadow-none!",
    // Segments never translate on their own — only the whole frame moves.
    "[&>:is(button,a)]:hover:translate-y-0! [&>:is(button,a)]:active:translate-x-0! [&>:is(button,a)]:active:translate-y-0!",
    // The WHOLE group presses into its shadow like a single Button: hover sinks it
    // toward the shadow, press sits it flush (shadow gone).
    "has-[:is(button,a):hover]:translate-y-1 has-[:is(button,a):hover]:shadow",
    "has-[:is(button,a):active]:translate-y-2 has-[:is(button,a):active]:translate-x-1 has-[:is(button,a):active]:shadow-none",
    // When a group CONTAINS nested groups it becomes a plain gapped container
    // (no frame / shadow / press) so each nested sub-group is its own pressable
    // unit instead of pressing the whole outer frame.
    "has-[>[data-slot=button-group]]:gap-2 has-[>[data-slot=button-group]]:border-0 has-[>[data-slot=button-group]]:bg-transparent has-[>[data-slot=button-group]]:shadow-none! has-[>[data-slot=button-group]]:translate-x-0! has-[>[data-slot=button-group]]:translate-y-0!",
    // The hovered/pressed segment darkens in place via a full-bleed inset tint, so
    // you can still tell which segment you're acting on while the frame presses.
    "[&>:is(button,a):hover]:shadow-[inset_0_0_0_999px_#00000012]! [&>:is(button,a):active]:shadow-[inset_0_0_0_999px_#0000001f]!",

    "dark:[&>:is(button,a):hover]:shadow-[inset_0_0_0_999px_#ffffff1f]! dark:[&>:is(button,a):active]:shadow-[inset_0_0_0_999px_#ffffff33]!",
    // Solid dark segments (secondary) always lighten on hover — a dark tint would
    // be invisible on a black button in light mode.
    "[&>:is(button,a)[data-variant=secondary]:hover]:shadow-[inset_0_0_0_999px_#ffffff2b]! [&>:is(button,a)[data-variant=secondary]:active]:shadow-[inset_0_0_0_999px_#ffffff45]!",
    "[&>:is(button,a):focus-visible]:relative [&>:is(button,a):focus-visible]:z-10"
  ),
  {
    variants: {
      orientation: {
        // A single crisp seam between segments (logical side, so RTL-correct).
        horizontal:
          "flex-row [&>*:not(:first-child)]:border-s-2! [&>*:not(:first-child)]:border-foreground",
        vertical:
          "flex-col [&>*:not(:first-child)]:border-t-2! [&>*:not(:first-child)]:border-foreground",
      },
    },
    defaultVariants: {
      orientation: "horizontal",
    },
  }
)

function ButtonGroup({
  className,
  orientation,
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
  return (
    <div
      role="group"
      data-slot="button-group"
      data-orientation={orientation ?? "horizontal"}
      className={cn(buttonGroupVariants({ orientation }), className)}
      {...props}
    />
  )
}

function ButtonGroupText({
  className,
  asChild = false,
  ...props
}: React.ComponentProps<"div"> & {
  asChild?: boolean
}) {
  const Comp = asChild ? Slot.Root : "div"

  return (
    <Comp
      data-slot="button-group-text"
      className={cn(
        "inline-flex items-center gap-2 bg-muted px-3 font-head text-sm font-medium text-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function ButtonGroupSeparator({
  className,
  orientation = "vertical",
  ...props
}: React.ComponentProps<typeof Separator>) {
  return (
    <Separator
      data-slot="button-group-separator"
      orientation={orientation}
      className={cn(
        "relative z-10 self-stretch bg-border data-horizontal:h-0.5 data-horizontal:w-auto data-vertical:h-auto data-vertical:w-0.5",
        className
      )}
      {...props}
    />
  )
}

export {
  ButtonGroup,
  ButtonGroupSeparator,
  ButtonGroupText,
  buttonGroupVariants,
}

Обновите пути импорта в соответствии со структурой вашего проекта.

Использование

import {
  ButtonGroup,
  ButtonGroupSeparator,
  ButtonGroupText,
} from "@/components/ui/button-group"
<ButtonGroup>
  <Button>Button 1</Button>
  <Button>Button 2</Button>
</ButtonGroup>

Композиция

Используйте следующую композицию, чтобы собрать ButtonGroup:

ButtonGroup
├── Button or Input
├── ButtonGroupSeparator
└── ButtonGroupText

Доступность

  • У компонента ButtonGroup атрибут role установлен в group.
  • Используйте Tab, чтобы перемещаться между кнопками в группе.
  • Используйте aria-label или aria-labelledby, чтобы задать подпись группе кнопок.
<ButtonGroup aria-label="Button group">
  <Button>Button 1</Button>
  <Button>Button 2</Button>
</ButtonGroup>

ButtonGroup vs ToggleGroup

  • Используйте компонент ButtonGroup, когда хотите сгруппировать кнопки, выполняющие действие.
  • Используйте компонент ToggleGroup, когда хотите сгруппировать кнопки, переключающие состояние.

Примеры

Ориентация

Задайте проп orientation, чтобы изменить расположение группы кнопок.

import { MinusIcon, PlusIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

Размер

Управляйте размером кнопок с помощью пропа size на отдельных кнопках.

import { PlusIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

Вложенные

Вкладывайте компоненты <ButtonGroup> друг в друга, чтобы создать группы кнопок с отступами.

import { AudioLinesIcon, PlusIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

Разделитель

Компонент ButtonGroupSeparator визуально разделяет кнопки внутри группы.

Кнопкам с вариантом outline разделитель не нужен, так как у них есть граница. Для других вариантов разделитель рекомендуется, чтобы улучшить визуальную иерархию.

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

Разделённая

Создайте разделённую группу кнопок, добавив две кнопки, разделённые ButtonGroupSeparator.

import { IconPlus } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

Input

Оберните компонент Input кнопками.

import { SearchIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

Input Group

Оберните компонент InputGroup, чтобы создать сложные раскладки ввода.

"use client"

import * as React from "react"

Создайте разделённую группу кнопок с компонентом DropdownMenu.

"use client"

import {

Select

Сочетайте с компонентом Select.

"use client"

import * as React from "react"

Popover

Используйте с компонентом Popover.

import { BotIcon, ChevronDownIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

RTL

Чтобы включить поддержку RTL в Neobrutalism, см. руководство по настройке RTL.

"use client"

import * as React from "react"

Доступность

ButtonGroup рендерится с role="group": вспомогательные технологии понимают, что кнопки связаны между собой, а поведение клавиатуры при этом не меняется — у каждой кнопки остаётся своя остановка табуляции и штатная активация по Enter / Space. Дайте группе имя через aria-label или aria-labelledby, чтобы программы чтения с экрана называли контекст:

<ButtonGroup aria-label="Pagination">
  <Button>Previous</Button>
  <Button>Next</Button>
</ButtonGroup>

ButtonGroupSeparator оборачивает Separator из Base UI, а тот рендерится с role="separator" и подходящим aria-orientation: он объявляется как разделитель и никогда не получает фокус. Если нужна одна остановка табуляции с переходом между кнопками по стрелкам — это паттерн WAI-ARIA Toolbar, а этот компонент намеренно сохраняет обычный порядок обхода.

Справочник API

ButtonGroup

Компонент ButtonGroup — это контейнер, который объединяет связанные кнопки с единообразным оформлением.

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"
<ButtonGroup>
  <Button>Button 1</Button>
  <Button>Button 2</Button>
</ButtonGroup>

Вкладывайте несколько групп кнопок друг в друга, чтобы создавать сложные раскладки с отступами. Подробнее см. в примере вложенные.

<ButtonGroup>
  <ButtonGroup />
  <ButtonGroup />
</ButtonGroup>

ButtonGroupSeparator

Компонент ButtonGroupSeparator визуально разделяет кнопки внутри группы.

PropTypeDefault
orientation"horizontal" | "vertical""vertical"
<ButtonGroup>
  <Button>Button 1</Button>
  <ButtonGroupSeparator />
  <Button>Button 2</Button>
</ButtonGroup>

ButtonGroupText

Используйте этот компонент, чтобы показать текст внутри группы кнопок.

PropTypeDefault
asChildbooleanfalse
<ButtonGroup>
  <ButtonGroupText>Text</ButtonGroupText>
  <Button>Button</Button>
</ButtonGroup>

Используйте проп asChild, чтобы отобразить в качестве текста собственный компонент — например, label.

import { ButtonGroupText } from "@/components/ui/button-group"
import { Label } from "@/components/ui/label"
 
export function ButtonGroupTextDemo() {
  return (
    <ButtonGroup>
      <ButtonGroupText asChild>
        <Label htmlFor="name">Text</Label>
      </ButtonGroupText>
      <Input placeholder="Type something here..." id="name" />
    </ButtonGroup>
  )
}