콘텐츠로 건너뛰기

버튼 그룹

퓨즈는 버튼, 도구 모음, 입력 애드온을 하나의 테두리가 있는 단위로 통합합니다. 두꺼운 테두리 하나, 강한 그림자 하나, 그리고 하나의 통합된 클릭 동작이 적용됩니다.

"use client"

import * as React from "react"

버튼 그룹은 나란히 놓인 버튼, 입력, 레이블을 하나의 세그먼트 컨트롤로 융합합니다. 아래에 깔린 프리미티브는 없습니다. 평범한 role="group" 컨테이너가 네오브루탈리즘 프레임(두꺼운 테두리 하나, 선명한 그림자 하나, 함께 움직이는 누름 애니메이션 하나)을 맡고, 프리미티브를 끌어다 쓰는 것은 Separator를 감싸는 ButtonGroupSeparator뿐입니다.

다음과 같은 경우에 사용합니다.

  • 분할 버튼 — 주요 동작 하나에 대안을 담은 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 경로를 업데이트합니다.

사용법

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 prop을 설정해 버튼 그룹의 레이아웃을 변경합니다.

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

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

크기

개별 버튼의 size prop을 사용해 버튼 크기를 제어합니다.

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

Neobrutalism에서 RTL을 활성화하는 방법은 RTL 설정 가이드를 참조하세요.

"use client"

import * as React from "react"

접근성

ButtonGrouprole="group"을 렌더링합니다. 이는 버튼들이 한 묶음이라는 사실을 보조 기술에 알려 주지만 키보드 동작은 바꾸지 않습니다. 각 버튼은 자신의 탭 정지를 그대로 유지하고 Enter / Space로 기본 활성화됩니다. 스크린 리더가 맥락을 함께 읽어 줄 수 있도록 aria-label이나 aria-labelledby로 그룹에 이름을 지정하세요.

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

ButtonGroupSeparator는 Base UI Separator를 감쌉니다. 이 요소는 방향에 맞는 aria-orientation과 함께 role="separator"로 렌더링되어 구분선으로 안내되며, 포커스는 받지 않습니다. 버튼 사이를 화살표 키로 이동하면서 탭 정지를 하나만 두려면 그것은 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 prop을 사용하면 커스텀 컴포넌트(예: 레이블)를 텍스트로 렌더링할 수 있습니다.

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