Nhóm nút
Fuses kết hợp các nút chia nhỏ, thanh công cụ và tiện ích nhập liệu thành một đơn vị có khung — một đường viền dày, một bóng đổ đậm, một thao tác nhấn chung.
"use client"
import * as React from "react"Button group gộp các nút, input và nhãn liền kề thành một điều khiển phân đoạn duy nhất. Không có primitive nào bên dưới — một vùng chứa role="group" thuần đảm nhiệm cả khung neobrutalist (một đường viền dày, một bóng đổ đậm, một hiệu ứng nhấn dùng chung); chỉ ButtonGroupSeparator mới cần đến một primitive, khi nó bọc Separator.
Nên dùng khi:
- Nút chia tách — một hành động chính cộng thêm một
DropdownMenucác phương án khác: Save / Save as…, Merge / Squash. - Thanh phân trang và thanh công cụ — Previous/Next, thu/phóng, bộ chuyển chế độ xem dồn trong một khung.
- Phần bổ trợ cho input — một ô tìm kiếm kèm nút gửi, một nhãn tiền tố URL, một nút sao chép ở hậu tố.
Cài đặt#
pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/button-group.jsonnpx shadcn@latest add https://neobrutalism.com/r/base/button-group.jsonyarn dlx shadcn@latest add https://neobrutalism.com/r/base/button-group.jsonbunx --bun shadcn@latest add https://neobrutalism.com/r/base/button-group.json
Cài đặt các dependency sau:
pnpm add @base-ui/reactnpm install @base-ui/reactyarn add @base-ui/reactbun add @base-ui/react
Sao chép và dán đoạn mã sau vào dự án của bạn.
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,
}
Cập nhật các đường dẫn import cho khớp với cấu trúc dự án của bạn.
Cách dùng#
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/components/ui/button-group"<ButtonGroup>
<Button>Button 1</Button>
<Button>Button 2</Button>
</ButtonGroup>Kết hợp#
Dùng cách kết hợp sau để dựng một ButtonGroup:
ButtonGroup
├── Button or Input
├── ButtonGroupSeparator
└── ButtonGroupTextButtonGroup vs ToggleGroup#
- Dùng thành phần
ButtonGroupkhi bạn muốn nhóm các nút thực hiện một hành động. - Dùng thành phần
ToggleGroupkhi bạn muốn nhóm các nút chuyển đổi một trạng thái.
Ví dụ#
Hướng#
Đặt prop orientation để thay đổi bố cục của nhóm nút.
import { MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"Kích thước#
Kiểm soát kích thước của các nút bằng prop size trên từng nút.
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"Lồng nhau#
Lồng các thành phần <ButtonGroup> để tạo các nhóm nút có khoảng cách.
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"Dấu phân cách#
Thành phần ButtonGroupSeparator chia các nút bên trong một nhóm về mặt trực quan.
Các nút có biến thể outline không cần dấu phân cách vì chúng đã có viền. Với các biến thể khác, nên dùng dấu phân cách để cải thiện hệ phân cấp trực quan.
import { Button } from "@/components/ui/button"
import {
ButtonGroup,Tách#
Tạo một nhóm nút tách bằng cách thêm hai nút được ngăn cách bởi một ButtonGroupSeparator.
import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"Input#
Bọc một thành phần Input bằng các nút.
import { SearchIcon } from "lucide-react"
import { Button } from "@/components/ui/button"Input Group#
Bọc một thành phần InputGroup để tạo các bố cục nhập liệu phức tạp.
"use client"
import * as React from "react"Dropdown Menu#
Tạo một nhóm nút tách với một thành phần DropdownMenu.
"use client"
import {Select#
Ghép với một thành phần Select.
"use client"
import * as React from "react"Popover#
Dùng với một thành phần Popover.
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"RTL#
Để bật hỗ trợ RTL trong Neobrutalism, xem hướng dẫn cấu hình RTL.
"use client"
import * as React from "react"Khả năng truy cập#
ButtonGroup render role="group", cho công nghệ hỗ trợ biết rằng các nút này thuộc về nhau mà không thay đổi hành vi bàn phím — mỗi nút vẫn giữ điểm dừng Tab riêng và cách kích hoạt native bằng Enter / Space. Hãy đặt tên cho nhóm bằng aria-label hoặc aria-labelledby để trình đọc màn hình nêu được ngữ cảnh:
<ButtonGroup aria-label="Pagination">
<Button>Previous</Button>
<Button>Next</Button>
</ButtonGroup>ButtonGroupSeparator bọc Base UI Separator, thành phần này render role="separator" kèm aria-orientation tương ứng — được đọc lên như một đường phân chia và không bao giờ nhận focus. Nếu bạn cần một điểm dừng Tab duy nhất với điều hướng bằng phím mũi tên giữa các nút, đó là mẫu Toolbar của WAI-ARIA — thành phần này cố tình giữ thứ tự Tab thông thường.
Tham chiếu API#
ButtonGroup#
Thành phần ButtonGroup là một khối chứa nhóm các nút liên quan lại với nhau bằng một kiểu dáng nhất quán.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
<ButtonGroup>
<Button>Button 1</Button>
<Button>Button 2</Button>
</ButtonGroup>Lồng nhiều nhóm nút để tạo các bố cục phức tạp có khoảng cách. Xem ví dụ lồng nhau để biết thêm chi tiết.
<ButtonGroup>
<ButtonGroup />
<ButtonGroup />
</ButtonGroup>ButtonGroupSeparator#
Thành phần ButtonGroupSeparator chia các nút bên trong một nhóm về mặt trực quan.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "vertical" |
<ButtonGroup>
<Button>Button 1</Button>
<ButtonGroupSeparator />
<Button>Button 2</Button>
</ButtonGroup>ButtonGroupText#
Dùng thành phần này để hiển thị văn bản bên trong một nhóm nút.
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
<ButtonGroup>
<ButtonGroupText>Text</ButtonGroupText>
<Button>Button</Button>
</ButtonGroup>Dùng prop asChild để hiển thị một thành phần tùy chỉnh làm văn bản, ví dụ một 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>
)
}