Group
A component for visually grouping a series of controls. Built with Base UI and Tailwind CSS. Copy-paste ready.
import {
ArchiveIcon,
EditIcon,
EllipsisIcon,
FilesIcon,
FilmIcon,
ShareIcon,
TrashIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
export default function Particle() {
return (
<Group aria-label="File actions">
<Button variant="outline">
<FilesIcon aria-hidden="true" />
Files
</Button>
<GroupSeparator />
<Button variant="outline">
<FilmIcon aria-hidden="true" />
Media
</Button>
<GroupSeparator />
<Menu>
<MenuTrigger
render={<Button aria-label="Menu" size="icon" variant="outline" />}
>
<EllipsisIcon className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem>
<EditIcon aria-hidden="true" />
Edit
</MenuItem>
<MenuItem>
<ArchiveIcon aria-hidden="true" />
Archive
</MenuItem>
<MenuItem>
<ShareIcon aria-hidden="true" />
Share
</MenuItem>
<MenuItem variant="destructive">
<TrashIcon aria-hidden="true" />
Delete
</MenuItem>
</MenuPopup>
</Menu>
</Group>
);
}
Installation
pnpm dlx shadcn@latest add @cnippet/group
Usage
import { Button } from "@/components/ui/button"
import { Group, GroupSeparator } from "@/components/ui/group"<Group>
<Button>Button</Button>
<GroupSeparator />
<Button>Button</Button>
</Group>Examples
With Input
Combines a Group with an Input for a compound field where the input and an action button share a single visual container.
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useRef } from "react";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipPopup,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Particle() {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const inputRef = useRef<HTMLInputElement>(null);
return (
<Group aria-label="Url input">
<Input
aria-label="Url"
defaultValue="https://ui.cnippet.dev"
ref={inputRef}
type="text"
/>
<GroupSeparator />
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Copy"
onClick={() => {
if (inputRef.current) {
copyToClipboard(inputRef.current.value);
}
}}
size="icon"
variant="outline"
/>
}
>
{isCopied ? <CheckIcon /> : <CopyIcon />}
</TooltipTrigger>
<TooltipPopup>
<p>Copy to clipboard</p>
</TooltipPopup>
</Tooltip>
</Group>
);
}
Small Size
Renders a Group at sm size so all enclosed controls shrink proportionally for compact toolbar or inline contexts.
import {
ArchiveIcon,
EditIcon,
EllipsisIcon,
FilesIcon,
FilmIcon,
ShareIcon,
TrashIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
export default function Particle() {
return (
<Group aria-label="File actions">
<Button size="sm" variant="outline">
<FilesIcon aria-hidden="true" />
Files
</Button>
<GroupSeparator />
<Button size="sm" variant="outline">
<FilmIcon aria-hidden="true" />
Media
</Button>
<GroupSeparator />
<Menu>
<MenuTrigger
render={<Button aria-label="Menu" size="icon-sm" variant="outline" />}
>
<EllipsisIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem>
<EditIcon aria-hidden="true" />
Edit
</MenuItem>
<MenuItem>
<ArchiveIcon aria-hidden="true" />
Archive
</MenuItem>
<MenuItem>
<ShareIcon aria-hidden="true" />
Share
</MenuItem>
<MenuItem variant="destructive">
<TrashIcon aria-hidden="true" />
Delete
</MenuItem>
</MenuPopup>
</Menu>
</Group>
);
}
With Disabled Button
Shows how a disabled button integrates visually within the grouped control, preventing interaction without breaking the layout.
import {
ArchiveIcon,
EditIcon,
EllipsisIcon,
FilesIcon,
FilmIcon,
ShareIcon,
TrashIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
export default function Particle() {
return (
<Group aria-label="File actions">
<Button variant="outline">
<FilesIcon aria-hidden="true" />
Files
</Button>
<GroupSeparator />
<Button disabled variant="outline">
<FilmIcon aria-hidden="true" />
Media
</Button>
<GroupSeparator />
<Menu>
<MenuTrigger
render={<Button aria-label="Menu" size="icon" variant="outline" />}
>
<EllipsisIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem>
<EditIcon aria-hidden="true" />
Edit
</MenuItem>
<MenuItem>
<ArchiveIcon aria-hidden="true" />
Archive
</MenuItem>
<MenuItem>
<ShareIcon aria-hidden="true" />
Share
</MenuItem>
<MenuItem variant="destructive">
<TrashIcon aria-hidden="true" />
Delete
</MenuItem>
</MenuPopup>
</Menu>
</Group>
);
}
With Default Buttons
A baseline group of default-style buttons demonstrating the shared border collapse and corner rounding behavior.
import {
ArchiveIcon,
EditIcon,
EllipsisIcon,
FilesIcon,
FilmIcon,
ShareIcon,
TrashIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
export default function Particle() {
return (
<Group aria-label="File actions">
<Button>
<FilesIcon aria-hidden="true" />
Files
</Button>
<GroupSeparator className="bg-primary/72" />
<Button>
<FilmIcon aria-hidden="true" />
Media
</Button>
<GroupSeparator className="bg-primary/72" />
<Menu>
<MenuTrigger render={<Button aria-label="Menu" size="icon" />}>
<EllipsisIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem>
<EditIcon aria-hidden="true" />
Edit
</MenuItem>
<MenuItem>
<ArchiveIcon aria-hidden="true" />
Archive
</MenuItem>
<MenuItem>
<ShareIcon aria-hidden="true" />
Share
</MenuItem>
<MenuItem variant="destructive">
<TrashIcon aria-hidden="true" />
Delete
</MenuItem>
</MenuPopup>
</Menu>
</Group>
);
}
With Prefix Label
Prepends a static text label to the group as the first item, anchoring a unit or field prefix such as "https://".
import { Group, GroupSeparator, GroupText } from "@/components/ui/group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Particle() {
return (
<Group aria-label="Domain input">
<GroupText render={<Label aria-label="Domain" htmlFor="domain" />}>
https://
</GroupText>
<GroupSeparator />
<Input
aria-label="Domain"
defaultValue="ui.cnippet.dev"
id="domain"
type="text"
/>
</Group>
);
}
With Suffix Label
Appends a static text suffix or unit label at the trailing end of the group, such as ".com" or "kg".
import { Group, GroupSeparator, GroupText } from "@/components/ui/group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Particle() {
return (
<Group aria-label="Domain input">
<Input
aria-label="Domain"
defaultValue="coss"
id="domain-suffix"
type="text"
/>
<GroupSeparator />
<GroupText
render={<Label aria-label="Domain suffix" htmlFor="domain-suffix" />}
>
.com
</GroupText>
</Group>
);
}
Vertical
Stacks group items vertically instead of horizontally for menu-like or stacked control layouts.
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
export default function Particle() {
return (
<Group aria-label="Zoom controls" orientation="vertical">
<Button aria-label="Zoom in" size="icon" variant="outline">
<ZoomInIcon />
</Button>
<GroupSeparator orientation="horizontal" />
<Button aria-label="Zoom Out" size="icon" variant="outline">
<ZoomOutIcon />
</Button>
</Group>
);
}
Nested Groups
Demonstrates grouping within a group, allowing complex compound controls with multiple separators and sub-sections.
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
export default function Particle() {
return (
<Group aria-label="Pagination">
<Group aria-label="Page numbers">
<Button className="min-w-8" variant="outline">
1
</Button>
<GroupSeparator />
<Button className="min-w-8" variant="outline">
2
</Button>
<GroupSeparator />
<Button className="min-w-8" variant="outline">
3
</Button>
<GroupSeparator />
<Button className="min-w-8" variant="outline">
4
</Button>
<GroupSeparator />
<Button className="min-w-8" variant="outline">
5
</Button>
</Group>
<Group aria-label="Navigation">
<Button aria-label="Previous" size="icon" variant="outline">
<ArrowLeftIcon aria-hidden="true" />
</Button>
<GroupSeparator />
<Button aria-label="Next" size="icon" variant="outline">
<ArrowRightIcon aria-hidden="true" />
</Button>
</Group>
</Group>
);
}
With Popup
Attaches a Popover or dropdown to a button inside the group, enabling inline contextual actions.
import { ChevronDownIcon, GitForkIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
export default function Particle() {
return (
<Group aria-label="Repository actions">
<Button variant="outline">
<GitForkIcon aria-hidden="true" />
Fork
<Badge variant="secondary">48</Badge>
</Button>
<GroupSeparator />
<Popover>
<PopoverTrigger
render={
<Button aria-label="Send options" size="icon" variant="outline" />
}
>
<ChevronDownIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverPopup align="end" className="w-64">
<PopoverTitle className="text-base">Existing forks</PopoverTitle>
<PopoverDescription>
You don't have any forks of this repository.
</PopoverDescription>
</PopoverPopup>
</Popover>
</Group>
);
}
With Input Group
Nests an InputGroup inside a Group for more complex compound field arrangements with multiple inline adornments.
import { MicIcon, PaperclipIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group } from "@/components/ui/group";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Particle() {
return (
<Group
aria-label="Message composer"
className="[--radius-lg:9999px] [--radius:9999rem]"
>
<Group aria-label="Attachments">
<Button aria-label="Attach file" size="icon" variant="outline">
<PaperclipIcon aria-hidden="true" />
</Button>
</Group>
<Group aria-label="Message input">
<InputGroup>
<InputGroupInput placeholder="Send a message" />
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Voice Mode"
size="icon-xs"
variant="ghost"
/>
}
>
<MicIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>Voice Mode</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</Group>
</Group>
);
}
With Menu
Embeds a Menu trigger inside the group to create a split-button or action group with a dropdown for secondary options.
import {
ChevronDownIcon,
DownloadIcon,
EditIcon,
ShareIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
export default function Particle() {
return (
<Group aria-label="Subscription actions">
<Button>Subscribe</Button>
<GroupSeparator className="bg-primary/72" />
<Menu>
<MenuTrigger render={<Button aria-label="Copy options" size="icon" />}>
<ChevronDownIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem>
<ShareIcon aria-hidden="true" />
Share link
</MenuItem>
<MenuItem>
<DownloadIcon aria-hidden="true" />
Download
</MenuItem>
<MenuItem>
<EditIcon aria-hidden="true" />
Duplicate
</MenuItem>
</MenuPopup>
</Menu>
</Group>
);
}
With Select
Attaches a Select dropdown to the group so users can choose between options alongside an adjacent action button.
"use client";
import { ArrowRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
} from "@/components/ui/number-field";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface Currency {
value: string;
label: string;
}
const currencies: Currency[] = [
{
label: "US Dollar",
value: "$",
},
{
label: "Euro",
value: "€",
},
{
label: "British Pound",
value: "£",
},
];
export default function Particle() {
return (
<Group aria-label="Payment amount">
<Group aria-label="Amount input">
<Select
defaultValue={currencies[0]}
itemToStringValue={(currency) => currency.value}
>
<SelectTrigger className="w-fit min-w-none">
<SelectValue>{(currency: Currency) => currency.value}</SelectValue>
</SelectTrigger>
<SelectPopup className="min-w-48">
{currencies.map((curr) => (
<SelectItem key={curr.value} value={curr}>
{curr.value} <span className="ms-1">{curr.label}</span>
</SelectItem>
))}
</SelectPopup>
</Select>
<GroupSeparator />
<NumberField
aria-label="Enter the amount"
className="gap-0"
defaultValue={10}
render={<NumberFieldGroup />}
>
<NumberFieldInput className="text-left" />
</NumberField>
</Group>
<Group aria-label="Submit">
<Button aria-label="Send" size="icon" variant="outline">
<ArrowRightIcon aria-hidden="true" />
</Button>
</Group>
</Group>
);
}
Text Formatting Toolbar
Four icon-toggle buttons (bold, italic, underline, strikethrough) that track active state with aria-pressed — useful for rich-text editors.
"use client";
import {
BoldIcon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
} from "lucide-react";
import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
const formats = [
{ icon: BoldIcon, label: "Bold", value: "bold" },
{ icon: ItalicIcon, label: "Italic", value: "italic" },
{ icon: UnderlineIcon, label: "Underline", value: "underline" },
{ icon: StrikethroughIcon, label: "Strikethrough", value: "strikethrough" },
] as const;
export default function Particle() {
const [active, setActive] = useState<Set<string>>(new Set(["bold"]));
const toggle = (value: string) => {
setActive((prev) => {
const next = new Set(prev);
next.has(value) ? next.delete(value) : next.add(value);
return next;
});
};
return (
<Group aria-label="Text formatting">
{formats.map((fmt, i) => (
<Fragment key={fmt.value}>
{i > 0 && <GroupSeparator />}
<Button
aria-label={fmt.label}
aria-pressed={active.has(fmt.value)}
onClick={() => toggle(fmt.value)}
size="icon"
variant={active.has(fmt.value) ? "secondary" : "outline"}
>
<fmt.icon aria-hidden="true" />
</Button>
</Fragment>
))}
</Group>
);
}
Thumbs Up / Down Vote
Two reaction buttons with live counts using Badge. Clicking toggles the vote and updates the count in real time.
"use client";
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
export default function Particle() {
const [votes, setVotes] = useState({ down: 4, up: 42 });
const [voted, setVoted] = useState<"up" | "down" | null>(null);
const vote = (dir: "up" | "down") => {
setVotes((v) => {
if (voted === dir) return { ...v, [dir]: v[dir] - 1 };
const next = { ...v, [dir]: v[dir] + 1 };
if (voted) next[voted] = v[voted] - 1;
return next;
});
setVoted((prev) => (prev === dir ? null : dir));
};
return (
<Group aria-label="Helpfulness vote">
<Button
aria-label="Helpful"
aria-pressed={voted === "up"}
onClick={() => vote("up")}
variant={voted === "up" ? "secondary" : "outline"}
>
<ThumbsUpIcon aria-hidden="true" />
Helpful
<Badge className="-me-1" variant="secondary">
{votes.up}
</Badge>
</Button>
<GroupSeparator />
<Button
aria-label="Not helpful"
aria-pressed={voted === "down"}
onClick={() => vote("down")}
variant={voted === "down" ? "secondary" : "outline"}
>
<ThumbsDownIcon aria-hidden="true" />
Not helpful
<Badge className="-me-1" variant="secondary">
{votes.down}
</Badge>
</Button>
</Group>
);
}
Media Player Controls
Previous, play/pause (stateful), and next icon buttons in a grouped row for compact audio or video playback interfaces.
"use client";
import {
PauseIcon,
PlayIcon,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
export default function Particle() {
const [playing, setPlaying] = useState(false);
return (
<Group aria-label="Media player controls">
<Button aria-label="Previous track" size="icon" variant="outline">
<SkipBackIcon aria-hidden="true" />
</Button>
<GroupSeparator />
<Button
aria-label={playing ? "Pause" : "Play"}
onClick={() => setPlaying((p) => !p)}
size="icon"
variant="outline"
>
{playing ? (
<PauseIcon aria-hidden="true" />
) : (
<PlayIcon aria-hidden="true" />
)}
</Button>
<GroupSeparator />
<Button aria-label="Next track" size="icon" variant="outline">
<SkipForwardIcon aria-hidden="true" />
</Button>
</Group>
);
}
Price Range Input
Dual numeric inputs with a currency prefix ($), a text separator (–), and an apply button — all connected in a single Group.
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator, GroupText } from "@/components/ui/group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Particle() {
return (
<Group aria-label="Price range">
<GroupText render={<Label htmlFor="price-min" />}>$</GroupText>
<GroupSeparator />
<Input
aria-label="Minimum price"
defaultValue="0"
id="price-min"
inputMode="numeric"
type="text"
/>
<GroupSeparator />
<GroupText>–</GroupText>
<GroupSeparator />
<Input
aria-label="Maximum price"
defaultValue="500"
inputMode="numeric"
type="text"
/>
<GroupSeparator />
<Button variant="outline">Apply</Button>
</Group>
);
}
Search with Category Filter
A Select for category, a text search input, and a search icon button composed into one compound search bar.
import { SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Group, GroupSeparator } from "@/components/ui/group";
import { Input } from "@/components/ui/input";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const categories = [
{ label: "All", value: "all" },
{ label: "Articles", value: "articles" },
{ label: "People", value: "people" },
{ label: "Files", value: "files" },
];
export default function Particle() {
return (
<Group aria-label="Search with category filter">
<Select defaultValue="all" items={categories}>
<SelectTrigger className="w-fit min-w-none">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{categories.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
<GroupSeparator />
<Input
aria-label="Search"
className="min-w-48"
placeholder="Search…"
type="search"
/>
<GroupSeparator />
<Button aria-label="Search" size="icon" variant="outline">
<SearchIcon aria-hidden="true" />
</Button>
</Group>
);
}
On This Page

