Button Group
Groups related buttons together with consistent styling.
Orientation
Button groups can be horizontal (default) or vertical.
Horizontal
Vertical
Separator
Buttons with variant outline do not need a separator since they have a border. For other variants, a separator is recommended.
Outline (no separator needed)
Solid with separator
Split Button
Combine a primary action with a dropdown trigger.
Nested
Button groups can be nested to create toolbar-like layouts with spacing between groups.
Input Integration
Combine inputs and buttons in a single group.
Search
URL input with text labels
With Text
ButtonGroupText displays non-interactive labels within a button group.
Size Variants
Buttons within a group can use different sizes.
Small
Default
Large
Icon
Grouped adjacent controls.
$ proa ui add button_groupInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Button Group recipe — typed root appearance and named slot classes.
//!
//! The root resolves orientation once. Descendant slots read their static
//! classes directly from [`BUTTON_GROUP_RECIPE`], and the separator responds
//! to the root's semantic `data-orientation` through a named Tailwind group.
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use crate::tw_join;
pub const BUTTON_GROUP_ROOT: &str = tw_join!(
"group/button-group",
"flex",
"w-fit",
"items-stretch",
"[&>*]:focus-visible:z-10",
"[&>*]:focus-visible:relative",
Button Group source
Browse and copy the reviewed source included with this Free component.
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::recipe::{ButtonGroupOrientation, ButtonGroupRecipeProps, BUTTON_GROUP_RECIPE};
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
pub struct ButtonGroup<C = ()> {
pub orientation: Option<ButtonGroupOrientation>,
pub children: Option<C>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Default for ButtonGroup<()> {
fn default() -> Self {
Self::new()
}
}
impl ButtonGroup<()> {
pub const fn new() -> Self {
Self {
orientation: None,
children: None,
class: None,
id: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for ButtonGroup<C>
{
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps {
orientation: self.orientation,
});
let orientation = style.orientation();
html_sync! {
<div
data-button-group
role="group"
data-slot="button-group"
data-orientation={orientation.data_value()}
class={proa_macros::text!("{} {}", style, self.class)}
id={self.id}
>
{self.children}
</div>
}
.render(cx)
}
}
/// ButtonGroupText - displays text/content within a button group
pub struct ButtonGroupText<C = ()> {
pub children: Option<C>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Default for ButtonGroupText<()> {
fn default() -> Self {
Self::new()
}
}
impl ButtonGroupText<()> {
pub const fn new() -> Self {
Self {
children: None,
class: None,
id: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for ButtonGroupText<C>
{
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-button-group-text
data-slot="button-group-text"
class={proa_macros::text!("{} {}", BUTTON_GROUP_RECIPE.text, self.class)}
id={self.id}
>
{self.children}
</div>
}
.render(cx)
}
}
/// ButtonGroupSeparator - visual separator between button group items
pub struct ButtonGroupSeparator {
pub orientation: Option<ButtonGroupOrientation>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Default for ButtonGroupSeparator {
fn default() -> Self {
Self::new()
}
}
impl ButtonGroupSeparator {
pub const fn new() -> Self {
Self {
orientation: Some(ButtonGroupOrientation::Vertical),
class: None,
id: None,
}
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for ButtonGroupSeparator {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let orientation = self.orientation.unwrap_or(ButtonGroupOrientation::Vertical);
html_sync! {
<div
data-button-group-separator
data-slot="button-group-separator"
data-orientation={orientation.data_value()}
role="separator"
aria-hidden="true"
class={proa_macros::text!("{} {}", BUTTON_GROUP_RECIPE.separator, self.class)}
id={self.id}
></div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
#[test]
fn test_button_group_horizontal() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroup {
orientation: Some(ButtonGroupOrientation::Horizontal),
children: Some("content"),
class: None,
id: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("data-orientation=\"horizontal\""));
assert!(html.contains("role=\"group\""));
assert!(html.contains("data-slot=\"button-group\""));
assert!(html.contains("group/button-group"));
}
#[test]
fn test_button_group_vertical() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroup {
orientation: Some(ButtonGroupOrientation::Vertical),
children: Some("content"),
class: None,
id: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("flex-col"));
}
#[test]
fn test_button_group_text() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroupText {
children: Some("Label"),
class: None,
id: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("bg-muted"));
assert!(html.contains("Label"));
assert!(html.contains("data-slot=\"button-group-text\""));
}
#[test]
fn test_button_group_separator() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroupSeparator {
..ButtonGroupSeparator::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("data-slot=\"button-group-separator\""));
assert!(html.contains("role=\"separator\""));
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("aria-hidden=\"true\""));
assert!(html.contains("group-data-[orientation=vertical]/button-group:h-px"));
}
#[test]
fn test_button_group_with_custom_class() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroup::<()> {
class: Some("custom-class"),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("custom-class"));
}
#[test]
fn test_button_group_slots_append_custom_classes() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroupText {
children: Some("Label"),
class: Some("text-override"),
id: None,
}
.render(&mut cx)
.unwrap();
ButtonGroupSeparator {
class: Some("separator-override"),
..ButtonGroupSeparator::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("text-override"));
assert!(html.contains("separator-override"));
}
#[test]
fn test_button_group_with_id() {
let mut buf = Vec::new();
let mut cx = Ctx::with_buffer(&mut buf);
ButtonGroup::<()> {
id: Some("test-id"),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(buf).unwrap();
assert!(html.contains("id=\"test-id\""));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{ButtonGroup, ButtonGroupSeparator, ButtonGroupText};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// ButtonGroup renders its children inline — the grouping chrome is meaningless
/// in markdown. In block context the labels form a standalone line, so it is
/// blank-line separated from surrounding blocks; inline the boundaries no-op.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for ButtonGroup<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
self.children.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
Ok(())
}
}
/// ButtonGroupText renders its children as inline text.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for ButtonGroupText<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let prev = cx.md_set_inline(true);
self.children.render_md(cx)?;
cx.md_set_inline(prev);
Ok(())
}
}
/// ButtonGroupSeparator renders as a single space so adjacent labels stay apart.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B>
for ButtonGroupSeparator
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { " " }.render_md(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn button_group_renders_children_inline() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
ButtonGroup {
children: Some((
ButtonGroupText {
children: Some("Save"),
class: None,
id: None,
},
ButtonGroupSeparator::new(),
ButtonGroupText {
children: Some("Cancel"),
class: None,
id: None,
},
)),
orientation: None,
class: None,
id: None,
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.starts_with("Save Cancel"), "output: {output}");
}
/// A button group rendered after mid-line prose must start on its own
/// line, blank-line separated, instead of fusing into the paragraph.
#[test]
fn button_group_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
ButtonGroup {
children: Some(ButtonGroupText {
children: Some("Save"),
class: None,
id: None,
}),
orientation: None,
class: None,
id: None,
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\nSave"),
"labels must be blank-line separated from preceding prose, got:\n{output}"
);
}
#[test]
fn button_group_separator_renders_space() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
ButtonGroupSeparator::new().render_md(&mut cx).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), " ");
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
pub mod button_group;
mod md;
pub mod recipe;
pub use button_group::{ButtonGroup, ButtonGroupSeparator, ButtonGroupText};
pub use recipe::{
button_group_recipe, ButtonGroupOrientation, ButtonGroupRecipe, ButtonGroupRecipeProps,
ResolvedButtonGroupRecipe, BUTTON_GROUP_RECIPE,
};
// Re-export metadata export function (only when feature is enabled)
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Button Group recipe — typed root appearance and named slot classes.
//!
//! The root resolves orientation once. Descendant slots read their static
//! classes directly from [`BUTTON_GROUP_RECIPE`], and the separator responds
//! to the root's semantic `data-orientation` through a named Tailwind group.
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use crate::tw_join;
pub const BUTTON_GROUP_ROOT: &str = tw_join!(
"group/button-group",
"flex",
"w-fit",
"items-stretch",
"[&>*]:focus-visible:z-10",
"[&>*]:focus-visible:relative",
"[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit",
"[&>input]:flex-1",
"has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md",
"has-[>[data-slot=button-group]]:gap-2"
);
pub const BUTTON_GROUP_TEXT: &str = tw_join!(
"bg-muted",
"text-muted-foreground",
"flex",
"items-center",
"gap-2",
"rounded-md",
"border",
"border-input",
"px-3",
"text-sm",
"font-medium",
"[&_svg]:pointer-events-none",
"[&_svg:not([class*='size-'])]:size-4"
);
/// A separator is vertical in a horizontal group and horizontal in a
/// vertical group. The named-group selectors make that relationship
/// authoritative at the root instead of requiring every separator to
/// repeat an orientation prop.
pub const BUTTON_GROUP_SEPARATOR: &str = tw_join!(
"bg-input",
"relative",
"!m-0",
"self-stretch",
"w-px",
"h-auto",
"group-data-[orientation=horizontal]/button-group:w-px",
"group-data-[orientation=horizontal]/button-group:h-auto",
"group-data-[orientation=vertical]/button-group:w-auto",
"group-data-[orientation=vertical]/button-group:h-px"
);
}
mod orientations {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum ButtonGroupOrientation {
#[default]
Horizontal,
Vertical,
}
impl ButtonGroupOrientation {
pub const fn classes(self) -> &'static str {
match self {
Self::Horizontal => "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
Self::Vertical => "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
}
}
pub const fn data_value(self) -> &'static str {
match self {
Self::Horizontal => "horizontal",
Self::Vertical => "vertical",
}
}
}
}
pub use orientations::ButtonGroupOrientation;
/// Optional root inputs accepted by [`ButtonGroupRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ButtonGroupRecipeProps {
pub orientation: Option<ButtonGroupOrientation>,
}
/// Static named slots for the Button Group compound component.
#[derive(Debug, Clone, Copy)]
pub struct ButtonGroupRecipe {
pub root: &'static str,
pub text: &'static str,
pub separator: &'static str,
orientation_default: ButtonGroupOrientation,
}
impl ButtonGroupRecipe {
/// Resolve the effective root orientation without allocating class storage.
pub const fn resolve(
&'static self,
props: ButtonGroupRecipeProps,
) -> ResolvedButtonGroupRecipe {
let orientation = match props.orientation {
Some(orientation) => orientation,
None => self.orientation_default,
};
ResolvedButtonGroupRecipe {
recipe: self,
orientation,
}
}
}
/// Fully resolved, streamable Button Group root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct ResolvedButtonGroupRecipe {
recipe: &'static ButtonGroupRecipe,
orientation: ButtonGroupOrientation,
}
impl ResolvedButtonGroupRecipe {
pub const fn orientation(self) -> ButtonGroupOrientation {
self.orientation
}
}
impl AttrValue for ResolvedButtonGroupRecipe {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.orientation.classes().as_bytes())
}
}
/// Global, zero-initialization Button Group recipe.
pub const BUTTON_GROUP_RECIPE: ButtonGroupRecipe = ButtonGroupRecipe {
root: base::BUTTON_GROUP_ROOT,
text: base::BUTTON_GROUP_TEXT,
separator: base::BUTTON_GROUP_SEPARATOR,
orientation_default: ButtonGroupOrientation::Horizontal,
};
pub const fn button_group_recipe() -> &'static ButtonGroupRecipe {
&BUTTON_GROUP_RECIPE
}
/// Compatibility resolver for callers of the former helper.
///
/// New render code should call [`ButtonGroupRecipe::resolve`] directly.
pub const fn button_group_classes(
orientation: ButtonGroupOrientation,
) -> ResolvedButtonGroupRecipe {
BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps {
orientation: Some(orientation),
})
}
/// Compatibility accessors for the former standalone class constants.
pub const fn button_group_text_classes() -> &'static str {
BUTTON_GROUP_RECIPE.text
}
pub const fn button_group_separator_classes() -> &'static str {
BUTTON_GROUP_RECIPE.separator
}
impl crate::variant_spec::ComponentSpec for ButtonGroupRecipe {
const NAME: &'static str = "ButtonGroup";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<
ButtonGroupOrientation,
>()]
}
}
/// Export metadata for TypeScript code generation.
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
crate::variant_spec::component_metadata(&BUTTON_GROUP_RECIPE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = button_group_recipe();
assert!(recipe.root.contains("group/button-group"));
assert!(recipe.text.contains("bg-muted"));
assert!(recipe
.separator
.contains("group-data-[orientation=vertical]"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps::default());
assert_eq!(default.orientation(), ButtonGroupOrientation::Horizontal);
let vertical = BUTTON_GROUP_RECIPE.resolve(ButtonGroupRecipeProps {
orientation: Some(ButtonGroupOrientation::Vertical),
});
assert_eq!(vertical.orientation(), ButtonGroupOrientation::Vertical);
assert!(vertical.orientation().classes().contains("flex-col"));
}
#[test]
fn separator_layout_is_owned_by_root_orientation() {
let separator = BUTTON_GROUP_RECIPE.separator;
assert!(separator.contains("group-data-[orientation=horizontal]/button-group:w-px"));
assert!(separator.contains("group-data-[orientation=vertical]/button-group:h-px"));
assert!(!separator
.split_ascii_whitespace()
.any(|class| class.starts_with("data-[orientation=")));
}
}