Button
Interactive button component with multiple variants, colors, and sizes.
Variants
Four visual styles: solid, outline, ghost, and link.
Colors
Primary, secondary, and destructive color schemes.
Sizes
Four size options.
Border Radius
Control the corner rounding.
States
Disabled and loading states.
FreeButton
Primary actions, secondary actions, and icon buttons.
Install with the Proa CLI
$ proa ui add buttonInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Button recipe - type-safe styling system for Button component
//!
//! Inspired by Panda CSS and Ark UI patterns, this module provides a fully
//! typed recipe system for generating Tailwind classes with compile-time safety.
//!
//! # Architecture
//!
//! This module defines:
//! 1. **Variant enums** (ButtonSize, ButtonColor, ButtonVariant, ButtonRadius)
//! 2. **ButtonRecipeProps** - Optional author inputs
//! 3. **ButtonRecipe** - Defaults plus canonical compound resolution
//! 4. **ButtonStyle** - A resolved, streamable attribute value
//!
//! # Theme Integration
//!
//! This recipe composes primitive design tokens from `theme.rs` into component-specific styles.
//! Following Panda CSS principles:
//! - **Theme contains primitives only**: `Colors::BLUE_600`, `Spacing::SPACING_4`, etc.
//! - **Theme provides utility modules**: `theme::bg`, `theme::text`, `theme::hover_bg`, etc.
Button source
Browse and copy the reviewed source included with this Free component.
button.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Button component implementation
use super::recipe::{
ButtonColor, ButtonRadius, ButtonRecipeProps, ButtonSize, ButtonVariant, BUTTON_RECIPE,
};
use crate::components::spinner::{Spinner, SpinnerSize};
use crate::free_web::binding_to_js_attr;
use proa_actions::ActionDataStatic;
#[cfg(test)]
use proa_core::ctx::Ctx;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_ir::ReactiveAttr;
use proa_macros::html_sync;
/// Stable DOM contract for Button across Rust and generated render targets.
pub const BUTTON_ANATOMY: crate::Anatomy = crate::Anatomy::new("button", &["root"]);
/// Button type for form behavior
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonType {
Button,
Submit,
Reset,
}
impl ButtonType {
pub const fn as_str(&self) -> &'static str {
match self {
ButtonType::Button => "button",
ButtonType::Submit => "submit",
ButtonType::Reset => "reset",
}
}
}
/// Type-safe Button component using ButtonRecipe.
///
/// Generic over the children type `C`, which can be any type implementing
/// `WebRender<NoLoader, Vec<u8>>`. This allows using `html_sync! { ... }` directly as children.
///
/// The button supports four independent styling dimensions:
/// - **size**: Sm, Md, Lg, Icon (controls height and padding)
/// - **color**: Primary, Secondary, Destructive (semantic color meaning)
/// - **variant**: Solid, Outline, Ghost (visual style)
/// - **radius**: None, Sm, Md, Lg, Full (border radius)
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Button, ButtonColor, ButtonVariant, ButtonSize};
/// use proa_macros::html_sync;
///
/// html_sync! {
/// <div>
/// {Button {
/// color: Some(ButtonColor::Destructive),
/// variant: Some(ButtonVariant::Outline),
/// size: Some(ButtonSize::Lg),
/// children: Some(html_sync!{ "Delete" }),
/// ..Button::default()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Button<C = ()> {
pub size: Option<ButtonSize>,
pub color: Option<ButtonColor>,
pub variant: Option<ButtonVariant>,
pub radius: Option<ButtonRadius>,
pub disabled: ReactiveAttr<bool>,
pub loading: ReactiveAttr<bool>,
pub button_type: ButtonType,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
pub children: Option<C>,
pub onclick: Option<ActionDataStatic>,
pub onblur: Option<ActionDataStatic>,
pub onfocus: Option<ActionDataStatic>,
}
impl Button<()> {
/// Create a new button with default settings and no children
pub const fn new() -> Self {
Self {
size: None,
color: None,
variant: None,
radius: None,
disabled: ReactiveAttr::static_value(false),
loading: ReactiveAttr::static_value(false),
button_type: ButtonType::Button,
class: None,
id: None,
children: None,
onclick: None,
onblur: None,
onfocus: None,
}
}
/// Create a button with text content
pub const fn text(text: &'static str) -> Button<&'static str> {
Button {
size: None,
color: None,
variant: None,
radius: None,
disabled: ReactiveAttr::static_value(false),
loading: ReactiveAttr::static_value(false),
button_type: ButtonType::Button,
class: None,
id: None,
children: Some(text),
onclick: None,
onblur: None,
onfocus: None,
}
}
}
impl<C> Default for Button<C>
where
C: Default,
{
fn default() -> Self {
Self {
size: None,
color: None,
variant: None,
radius: None,
disabled: ReactiveAttr::default(),
loading: ReactiveAttr::default(),
button_type: ButtonType::Button,
class: None,
id: None,
children: None,
onclick: None,
onblur: None,
onfocus: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Button<C>
{
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = BUTTON_RECIPE.resolve(ButtonRecipeProps {
size: self.size,
color: self.color,
variant: self.variant,
radius: self.radius,
});
let onclick_action = self.onclick.as_ref().map(|a| a.action);
let onblur_action = self.onblur.as_ref().map(|a| a.action);
let onfocus_action = self.onfocus.as_ref().map(|a| a.action);
let is_disabled = self.disabled.default || self.loading.default;
let disabled_binding_string = binding_to_js_attr(&self.disabled.binding);
let loading_binding_string = binding_to_js_attr(&self.loading.binding);
let disabled_binding = disabled_binding_string.as_deref();
let loading_binding = loading_binding_string.as_deref();
let is_loading = self.loading.default;
html_sync! {
<button
data-button
data-scope={BUTTON_ANATOMY.scope()}
data-part="root"
data-slot="button"
data-size={style.size().as_str()}
data-color={style.color().as_str()}
data-variant={style.variant().as_str()}
data-radius={style.radius().as_str()}
type={self.button_type.as_str()}
class={proa_macros::text!("{} {}", style, self.class)}
id={self.id}
disabled={is_disabled}
data-onclick={onclick_action}
data-onblur={onblur_action}
data-onfocus={onfocus_action}
data-bind-disabled={disabled_binding}
data-bind-loading={loading_binding}
>
{is_loading.then(|| Spinner { size: Some(SpinnerSize::Sm),..Spinner::default() })}
{self.children}
</button>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::text::text;
fn class_value(html: &str) -> &str {
html.split_once("class=\"")
.and_then(|(_, rest)| rest.split_once('\"'))
.map(|(classes, _)| classes)
.expect("button class attribute")
}
#[test]
fn button_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Button {
size: None,
color: None,
variant: None,
radius: None,
disabled: false.into(),
loading: false.into(),
button_type: ButtonType::Button,
class: None,
id: None,
children: Some(text("Click me")),
onclick: None,
onblur: None,
onfocus: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("inline-flex"));
assert!(html.contains("bg-primary"));
assert!(html.contains("hover:bg-primary/90"));
assert!(html.contains("h-9"));
assert!(html.contains("px-4"));
assert!(html.contains("Click me"));
assert!(html.contains("data-size=\"md\""));
assert!(html.contains("data-scope=\"button\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-slot=\"button\""));
assert!(html.contains("data-color=\"primary\""));
assert!(html.contains("data-variant=\"solid\""));
assert!(html.contains("data-radius=\"md\""));
}
#[test]
fn button_applies_color_and_size() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Button {
size: Some(ButtonSize::Lg),
color: Some(ButtonColor::Destructive),
variant: None,
radius: None,
disabled: false.into(),
loading: false.into(),
button_type: ButtonType::Button,
class: None,
id: None,
children: Some(text("Delete")),
onclick: None,
onblur: None,
onfocus: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("bg-destructive"));
assert!(html.contains("h-10"));
assert!(html.contains("px-6"));
assert!(html.contains("Delete"));
}
#[test]
fn button_applies_destructive_outline_compound() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Button {
size: Some(ButtonSize::Sm),
color: Some(ButtonColor::Destructive),
variant: Some(ButtonVariant::Outline),
radius: None,
disabled: false.into(),
loading: false.into(),
button_type: ButtonType::Button,
class: None,
id: None,
children: Some(text("Cancel")),
onclick: None,
onblur: None,
onfocus: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
let classes = class_value(&html);
assert!(classes
.split_ascii_whitespace()
.any(|class| class == "border-destructive"));
assert!(classes
.split_ascii_whitespace()
.any(|class| class == "bg-background"));
assert!(classes
.split_ascii_whitespace()
.any(|class| class == "text-destructive"));
assert!(!classes
.split_ascii_whitespace()
.any(|class| class == "bg-destructive"));
assert!(html.contains("h-8"));
assert!(html.contains("Cancel"));
assert!(html.contains("data-size=\"sm\""));
assert!(html.contains("data-color=\"destructive\""));
assert!(html.contains("data-variant=\"outline\""));
assert!(html.contains("data-radius=\"md\""));
}
#[test]
fn button_streams_custom_class_after_recipe_style() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let mut button = Button::text("Custom");
button.class = Some("button-test-hook");
button.render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
let classes = class_value(&html);
assert!(classes.starts_with("inline-flex"));
assert!(classes.ends_with("button-test-hook"));
assert!(classes
.split_ascii_whitespace()
.any(|class| class == "bg-primary"));
}
#[test]
fn button_handles_disabled_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Button {
size: None,
color: None,
variant: None,
radius: None,
disabled: true.into(),
loading: false.into(),
button_type: ButtonType::Button,
class: None,
id: None,
children: Some(text("Disabled")),
onclick: None,
onblur: None,
onfocus: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("disabled"));
assert!(html.contains("Disabled"));
}
#[test]
fn button_with_reactive_disabled() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let disabled = !ReactiveAttr::form_state(false, "isValid");
Button {
size: None,
color: None,
variant: None,
radius: None,
disabled,
loading: false.into(),
button_type: ButtonType::Submit,
class: None,
id: None,
children: Some(text("Submit")),
onclick: None,
onblur: None,
onfocus: None,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("disabled"));
assert!(html.contains("data-bind-disabled"));
assert!(html.contains("formState.isValid"));
}
#[test]
fn button_size_is_reasonable() {
assert!(std::mem::size_of::<Button<()>>() < 512);
const _BUTTON: Button<()> = Button::new();
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::Button;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Button renders as just its label text — the visual chrome is meaningless in markdown.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Button<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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn button_renders_label() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Button {
children: Some("Click me"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "Click me");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Button component - interactive element for triggering actions
//!
//! The Button component provides a type-safe builder API for creating buttons
//! with various styles, sizes, and states. Styling is handled by the ButtonRecipe
//! system which maps semantic variants to Tailwind CSS classes.
mod button;
mod md;
mod recipe;
// Re-export button types
pub use button::{Button, ButtonType, BUTTON_ANATOMY};
// Re-export recipe types (color + variant separation)
pub use recipe::{
button_recipe, ButtonColor, ButtonRadius, ButtonRecipe, ButtonRecipeProps, ButtonSize,
ButtonStyle, ButtonVariant, BUTTON_RECIPE,
};
// Re-export metadata export function (only when feature is enabled)
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Button recipe - type-safe styling system for Button component
//!
//! Inspired by Panda CSS and Ark UI patterns, this module provides a fully
//! typed recipe system for generating Tailwind classes with compile-time safety.
//!
//! # Architecture
//!
//! This module defines:
//! 1. **Variant enums** (ButtonSize, ButtonColor, ButtonVariant, ButtonRadius)
//! 2. **ButtonRecipeProps** - Optional author inputs
//! 3. **ButtonRecipe** - Defaults plus canonical compound resolution
//! 4. **ButtonStyle** - A resolved, streamable attribute value
//!
//! # Theme Integration
//!
//! This recipe composes primitive design tokens from `theme.rs` into component-specific styles.
//! Following Panda CSS principles:
//! - **Theme contains primitives only**: `Colors::BLUE_600`, `Spacing::SPACING_4`, etc.
//! - **Theme provides utility modules**: `theme::bg`, `theme::text`, `theme::hover_bg`, etc.
//! - **Recipes compose utilities**: Uses `const_format::concatcp!()` to compose at compile-time
//!
//! ## Using tw_join! for Composition
//!
//! We use the `tw_join!()` macro to compose theme constants with automatic spacing:
//!
//! ```ignore
//! use crate::theme::{bg, text, hover_bg, focus_ring};
//! use crate::tw_join;
//!
//! const BUTTON_PRIMARY: &str = tw_join!(
//! bg::BLUE_600,
//! text::WHITE,
//! hover_bg::BLUE_700,
//! focus_ring::BLUE_500
//! );
//! ```
//!
//! The `tw_join!` macro wraps `const_format::concatcp!()` and automatically adds spaces
//! between classes. Changing `Colors::BLUE_600` in theme.rs automatically propagates
//! through `bg::BLUE_600` to `BUTTON_PRIMARY`.
//!
//! # Usage Pattern
//!
//! Resolve the global recipe once, then render the returned style directly as
//! an attribute value.
//!
//! ```ignore
//! let style = BUTTON_RECIPE.resolve(ButtonRecipeProps {
//! size: Some(ButtonSize::Lg),
//! color: Some(ButtonColor::Primary),
//! ..ButtonRecipeProps::default()
//! });
//! ```
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles module - core button styling (shadcn style)
mod base {
use crate::theme::{
aria_invalid, focus_visible, font_size, font_weight, shadcn_utils, state, transition,
};
use crate::tw_join;
pub const BUTTON_BASE: &str = tw_join!(
shadcn_utils::INLINE_FLEX,
shadcn_utils::ITEMS_CENTER,
shadcn_utils::JUSTIFY_CENTER,
shadcn_utils::GAP_2,
shadcn_utils::WHITESPACE_NOWRAP,
font_size::TEXT_SM,
font_weight::FONT_MEDIUM,
transition::TRANSITION_ALL,
state::CURSOR_POINTER,
state::DISABLED_POINTER_EVENTS_NONE,
state::DISABLED_OPACITY_50,
shadcn_utils::SVG_POINTER_EVENTS_NONE,
shadcn_utils::SVG_SIZE_4,
shadcn_utils::SHRINK_0,
shadcn_utils::SVG_SHRINK_0,
focus_visible::OUTLINE_NONE,
focus_visible::BORDER_RING,
focus_visible::RING_RING,
focus_visible::RING_3,
aria_invalid::RING_DESTRUCTIVE,
aria_invalid::RING_DESTRUCTIVE_DARK,
aria_invalid::BORDER_DESTRUCTIVE
);
}
/// Size styles module - shadcn height-based sizing with gap
mod sizes {
use crate::theme::{height, padding, shadcn_utils};
use crate::tw_join;
// shadcn button sizes: md (h-9), sm (h-8), lg (h-10), icon (size-9)
const BUTTON_SIZE_MD: &str =
tw_join!(height::H_9, padding::PX_4, padding::PY_2, "has-[>svg]:px-3");
const BUTTON_SIZE_SM: &str = tw_join!(
height::H_8,
shadcn_utils::GAP_1_5,
padding::PX_3,
"has-[>svg]:px-2.5"
);
const BUTTON_SIZE_LG: &str = tw_join!(height::H_10, padding::PX_6, "has-[>svg]:px-4");
// Icon sizes - square buttons for icon-only use
const BUTTON_SIZE_ICON: &str = "size-9";
const BUTTON_SIZE_ICON_SM: &str = "size-8";
const BUTTON_SIZE_ICON_LG: &str = "size-10";
/// Button size variants (shadcn style)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum ButtonSize {
Sm,
#[default]
Md,
Lg,
/// Square icon button (default size, 36px)
Icon,
/// Small square icon button (32px)
IconSm,
/// Large square icon button (40px)
IconLg,
}
impl ButtonSize {
/// Get the Tailwind classes for this size
pub const fn classes(self) -> &'static str {
match self {
ButtonSize::Sm => BUTTON_SIZE_SM,
ButtonSize::Md => BUTTON_SIZE_MD,
ButtonSize::Lg => BUTTON_SIZE_LG,
ButtonSize::Icon => BUTTON_SIZE_ICON,
ButtonSize::IconSm => BUTTON_SIZE_ICON_SM,
ButtonSize::IconLg => BUTTON_SIZE_ICON_LG,
}
}
/// Get the stable value used by `data-size` and generated APIs.
pub const fn as_str(self) -> &'static str {
match self {
ButtonSize::Sm => "sm",
ButtonSize::Md => "md",
ButtonSize::Lg => "lg",
ButtonSize::Icon => "icon",
ButtonSize::IconSm => "icon-sm",
ButtonSize::IconLg => "icon-lg",
}
}
}
}
/// Color styles module - semantic button colors
/// Colors provide different classes based on the variant (solid, outline, ghost)
mod colors {
use crate::theme::{bg, hover_bg, text};
use crate::tw_join;
// ===== SOLID VARIANT COLORS =====
const SOLID_PRIMARY: &str =
tw_join!(bg::PRIMARY, text::PRIMARY_FOREGROUND, hover_bg::PRIMARY_90);
const SOLID_SECONDARY: &str = tw_join!(
bg::SECONDARY,
text::SECONDARY_FOREGROUND,
hover_bg::SECONDARY_80
);
const SOLID_DESTRUCTIVE: &str = tw_join!(
bg::DESTRUCTIVE,
text::WHITE,
"shadow-xs",
hover_bg::DESTRUCTIVE_80,
"focus-visible:ring-destructive/20",
"dark:focus-visible:ring-destructive/40"
);
// ===== OUTLINE VARIANT COLORS =====
// Outline: transparent bg, colored border + text, light colored hover
const OUTLINE_PRIMARY: &str = tw_join!(
"border",
"border-primary",
"bg-background",
"text-primary",
"shadow-xs",
"hover:bg-primary/10",
"dark:bg-transparent",
"dark:hover:bg-primary/20"
);
const OUTLINE_SECONDARY: &str = tw_join!(
"border",
"border-input",
"bg-background",
"text-foreground",
"shadow-xs",
"hover:bg-accent",
"hover:text-accent-foreground",
"dark:bg-input/30",
"dark:border-input",
"dark:hover:bg-input/50"
);
const OUTLINE_DESTRUCTIVE: &str = tw_join!(
"border",
"border-destructive",
"bg-background",
"text-destructive",
"shadow-xs",
"hover:bg-destructive/10",
"dark:bg-transparent",
"dark:hover:bg-destructive/20"
);
// ===== GHOST VARIANT COLORS =====
// Ghost: no border, no bg, colored text, light colored hover
const GHOST_PRIMARY: &str = tw_join!(
"text-primary",
"hover:bg-primary/10",
"hover:text-primary",
"dark:hover:bg-primary/20"
);
const GHOST_SECONDARY: &str = tw_join!(
"text-foreground",
"hover:bg-accent",
"hover:text-accent-foreground",
"dark:hover:bg-accent/50"
);
const GHOST_DESTRUCTIVE: &str = tw_join!(
"text-destructive",
"hover:bg-destructive/10",
"hover:text-destructive",
"dark:hover:bg-destructive/20"
);
// ===== LINK VARIANT COLORS =====
// Link: looks like a hyperlink - no bg, underline on hover
const LINK_PRIMARY: &str = tw_join!("text-primary", "underline-offset-4", "hover:underline");
const LINK_SECONDARY: &str =
tw_join!("text-foreground", "underline-offset-4", "hover:underline");
const LINK_DESTRUCTIVE: &str =
tw_join!("text-destructive", "underline-offset-4", "hover:underline");
/// Button color variants - semantic color choices
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "color")]
pub enum ButtonColor {
#[default]
Primary,
Secondary,
Destructive,
}
impl ButtonColor {
/// Get the Tailwind classes for this color with solid variant
pub const fn solid_classes(self) -> &'static str {
match self {
ButtonColor::Primary => SOLID_PRIMARY,
ButtonColor::Secondary => SOLID_SECONDARY,
ButtonColor::Destructive => SOLID_DESTRUCTIVE,
}
}
/// Get the Tailwind classes for this color with outline variant
pub const fn outline_classes(self) -> &'static str {
match self {
ButtonColor::Primary => OUTLINE_PRIMARY,
ButtonColor::Secondary => OUTLINE_SECONDARY,
ButtonColor::Destructive => OUTLINE_DESTRUCTIVE,
}
}
/// Get the Tailwind classes for this color with ghost variant
pub const fn ghost_classes(self) -> &'static str {
match self {
ButtonColor::Primary => GHOST_PRIMARY,
ButtonColor::Secondary => GHOST_SECONDARY,
ButtonColor::Destructive => GHOST_DESTRUCTIVE,
}
}
/// Get the Tailwind classes for this color with link variant
pub const fn link_classes(self) -> &'static str {
match self {
ButtonColor::Primary => LINK_PRIMARY,
ButtonColor::Secondary => LINK_SECONDARY,
ButtonColor::Destructive => LINK_DESTRUCTIVE,
}
}
/// Resolve the compound `color x variant` appearance in one place.
pub const fn classes_for(self, variant: super::ButtonVariant) -> &'static str {
match variant {
super::ButtonVariant::Solid => self.solid_classes(),
super::ButtonVariant::Outline => self.outline_classes(),
super::ButtonVariant::Ghost => self.ghost_classes(),
super::ButtonVariant::Link => self.link_classes(),
}
}
/// Get the stable value used by `data-color` and generated APIs.
pub const fn as_str(self) -> &'static str {
match self {
ButtonColor::Primary => "primary",
ButtonColor::Secondary => "secondary",
ButtonColor::Destructive => "destructive",
}
}
/// Legacy standalone color lookup.
///
/// Compound-aware consumers should use [`Self::classes_for`]. Component
/// metadata deliberately omits these classes from the independent color
/// axis and exports the complete `color x variant` matrix instead.
pub const fn classes(self) -> &'static str {
self.solid_classes()
}
}
}
/// Variant styles module - visual styling (solid, outline, ghost, link)
mod variants {
/// Button variant - visual style (independent of color)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "variant")]
pub enum ButtonVariant {
#[default]
Solid,
Outline,
Ghost,
/// Link variant - styled like a hyperlink with underline on hover
Link,
}
impl ButtonVariant {
/// Independent-axis metadata projection.
///
/// Appearance classes are resolved by `ButtonColor::classes_for` because
/// they depend on both the color and variant.
pub const fn classes(self) -> &'static str {
""
}
pub const fn as_str(self) -> &'static str {
match self {
ButtonVariant::Solid => "solid",
ButtonVariant::Outline => "outline",
ButtonVariant::Ghost => "ghost",
ButtonVariant::Link => "link",
}
}
}
}
/// Radius styles module - border radius variations
mod radius {
use crate::theme::rounded;
/// Button radius variants
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "radius")]
pub enum ButtonRadius {
None,
Sm,
#[default]
Md,
Lg,
Full,
}
impl ButtonRadius {
/// Get the Tailwind classes for this radius
pub const fn classes(self) -> &'static str {
match self {
ButtonRadius::None => rounded::NONE,
ButtonRadius::Sm => rounded::SM,
ButtonRadius::Md => rounded::MD,
ButtonRadius::Lg => rounded::LG,
ButtonRadius::Full => rounded::FULL,
}
}
/// Get the stable value used by `data-radius` and generated APIs.
pub const fn as_str(self) -> &'static str {
match self {
ButtonRadius::None => "none",
ButtonRadius::Sm => "sm",
ButtonRadius::Md => "md",
ButtonRadius::Lg => "lg",
ButtonRadius::Full => "full",
}
}
}
}
// Re-export all public types for convenience
pub use colors::ButtonColor;
pub use radius::ButtonRadius;
pub use sizes::ButtonSize;
pub use variants::ButtonVariant;
/// Inputs accepted by [`ButtonRecipe::resolve`].
///
/// Optional fields preserve the ergonomic component API while the resolved
/// [`ButtonStyle`] always contains concrete effective values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ButtonRecipeProps {
pub size: Option<ButtonSize>,
pub color: Option<ButtonColor>,
pub variant: Option<ButtonVariant>,
pub radius: Option<ButtonRadius>,
}
/// Fully resolved, zero-allocation Button style.
///
/// This value is `Copy` and implements [`AttrValue`], so it streams its static
/// class fragments directly into the response buffer when used in an HTML
/// attribute. It is the sole runtime class-resolution path for Button.
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ButtonStyle {
base: &'static str,
size: ButtonSize,
color: ButtonColor,
variant: ButtonVariant,
radius: ButtonRadius,
appearance: &'static str,
}
impl ButtonStyle {
pub const fn size(self) -> ButtonSize {
self.size
}
pub const fn color(self) -> ButtonColor {
self.color
}
pub const fn variant(self) -> ButtonVariant {
self.variant
}
pub const fn radius(self) -> ButtonRadius {
self.radius
}
/// Compatibility representation for callers of the pre-resolver recipe API.
const fn class_segments(self) -> ([&'static str; ButtonRecipe::CLASS_CAPACITY], usize) {
(
[
self.base,
self.size.classes(),
self.radius.classes(),
self.appearance,
"",
],
ButtonRecipe::RESOLVED_CLASS_COUNT,
)
}
}
impl AttrValue for ButtonStyle {
#[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.base.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.radius.classes().as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.appearance.as_bytes())
}
}
/// Fully typed button recipe with compile-time defaults.
///
/// This recipe separates color and variant as independent dimensions:
/// - **Color**: Primary, Secondary, Destructive (semantic meaning)
/// - **Variant**: Solid, Outline, Ghost (visual style)
/// - **Size**: Sm, Default, Lg, Icon (button dimensions)
/// - **Radius**: None, Sm, Md, Lg, Full (border radius)
///
/// This is a const struct that uses match-based lookups instead of hashmaps,
/// making it zero-cost at runtime with no initialization overhead.
///
/// This implementation is handwritten because the current `ProaRecipe` derive
/// only combines independent variant axes and cannot model `color x variant`
/// compound classes. The legacy `classes()` API remains available, but now
/// delegates to the same canonical resolver used by the component renderer.
pub struct ButtonRecipe {
base: &'static str,
size_default: Option<ButtonSize>,
color_default: Option<ButtonColor>,
variant_default: Option<ButtonVariant>,
radius_default: Option<ButtonRadius>,
}
/// Global button recipe instance - fully const, zero runtime initialization!
pub const BUTTON_RECIPE: ButtonRecipe = ButtonRecipe {
base: base::BUTTON_BASE,
size_default: Some(ButtonSize::Md),
color_default: Some(ButtonColor::Primary),
variant_default: Some(ButtonVariant::Solid),
radius_default: Some(ButtonRadius::Md),
};
impl ButtonRecipe {
/// Capacity of the legacy array representation. This remains five for
/// source compatibility with the former derived recipe; compound
/// resolution now needs only four populated fragments.
pub const CLASS_CAPACITY: usize = 5;
const RESOLVED_CLASS_COUNT: usize = 4;
/// Resolve optional author inputs into an effective, streamable style.
pub const fn resolve(&self, props: ButtonRecipeProps) -> ButtonStyle {
let size = match props.size {
Some(value) => value,
None => match self.size_default {
Some(value) => value,
None => ButtonSize::Md,
},
};
let color = match props.color {
Some(value) => value,
None => match self.color_default {
Some(value) => value,
None => ButtonColor::Primary,
},
};
let variant = match props.variant {
Some(value) => value,
None => match self.variant_default {
Some(value) => value,
None => ButtonVariant::Solid,
},
};
let radius = match props.radius {
Some(value) => value,
None => match self.radius_default {
Some(value) => value,
None => ButtonRadius::Md,
},
};
ButtonStyle {
base: self.base,
size,
color,
variant,
radius,
appearance: color.classes_for(variant),
}
}
/// Compatibility API for existing callers that consume recipe segments.
///
/// Unlike the old derived implementation, this includes the canonical
/// compound appearance selected by [`Self::resolve`]. New render code should
/// use `resolve()` directly to avoid the intermediate array.
pub const fn classes(
&self,
size: Option<ButtonSize>,
color: Option<ButtonColor>,
variant: Option<ButtonVariant>,
radius: Option<ButtonRadius>,
) -> ([&'static str; Self::CLASS_CAPACITY], usize) {
self.resolve(ButtonRecipeProps {
size,
color,
variant,
radius,
})
.class_segments()
}
}
impl crate::variant_spec::ComponentSpec for ButtonRecipe {
const NAME: &'static str = "Button";
fn base_classes(&self) -> &'static str {
self.base
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
use crate::variant_spec::{compound_axis_metadata, variant_metadata};
vec![
variant_metadata::<ButtonSize>(),
compound_axis_metadata::<ButtonColor>(),
compound_axis_metadata::<ButtonVariant>(),
variant_metadata::<ButtonRadius>(),
]
}
fn compound_variant_metadata(&self) -> Vec<crate::variant_spec::CompoundVariantMetadata> {
use crate::variant_spec::{CompoundVariantConditionMetadata, CompoundVariantMetadata};
let colors = [
ButtonColor::Primary,
ButtonColor::Secondary,
ButtonColor::Destructive,
];
let variants = [
ButtonVariant::Solid,
ButtonVariant::Outline,
ButtonVariant::Ghost,
ButtonVariant::Link,
];
let mut compounds = Vec::with_capacity(colors.len() * variants.len());
for color in colors {
for variant in variants {
compounds.push(CompoundVariantMetadata::new(
vec![
CompoundVariantConditionMetadata::new("color", color.as_str()),
CompoundVariantConditionMetadata::new("variant", variant.as_str()),
],
color.classes_for(variant),
));
}
}
compounds
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::BUTTON_ANATOMY)
}
}
/// Get the global button recipe with default Tailwind styling
///
/// This is now just a convenience function that returns a reference to the const recipe.
/// No initialization overhead, no synchronization, just a simple reference!
pub const fn button_recipe() -> &'static ButtonRecipe {
&BUTTON_RECIPE
}
/// Export metadata for TypeScript code generation
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
use crate::variant_spec::{
component_metadata_with_props, ComponentType, PropMetadata, RenderPosition,
};
component_metadata_with_props(
&BUTTON_RECIPE,
ComponentType::Button,
"button",
vec![
// Variant props - these map to recipe variants
PropMetadata::new("size", "ButtonSize").optional(),
PropMetadata::new("color", "ButtonColor").optional(),
PropMetadata::new("variant", "ButtonVariant").optional(),
PropMetadata::new("radius", "ButtonRadius").optional(),
// Button-specific props
PropMetadata::new("disabled", "boolean")
.optional()
.with_default("false")
.computed("disabled || loading"), // Computed: disabled when loading
PropMetadata::new("loading", "boolean")
.optional()
.with_default("false"),
PropMetadata::new("type", "ButtonType")
.optional()
.with_default("'button'")
.html_attr(),
PropMetadata::new("className", "string").optional(),
PropMetadata::new("id", "string").optional(),
// Event handlers
PropMetadata::new("onClick", "() => void").optional(),
PropMetadata::new("onBlur", "() => void").optional(),
PropMetadata::new("onFocus", "() => void").optional(),
// Children
PropMetadata::new("children", "React.ReactNode")
.optional()
.children(),
],
)
.with_dependency(
"Spinner",
"loading",
vec![("className", "\"animate-spin\"")],
RenderPosition::BeforeChildren,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolver_owns_all_defaults() {
let style = BUTTON_RECIPE.resolve(ButtonRecipeProps::default());
assert_eq!(style.size(), ButtonSize::Md);
assert_eq!(style.color(), ButtonColor::Primary);
assert_eq!(style.variant(), ButtonVariant::Solid);
assert_eq!(style.radius(), ButtonRadius::Md);
assert_eq!(style.appearance, ButtonColor::Primary.solid_classes());
}
#[test]
fn resolver_covers_every_color_variant_compound() {
let cases = [
(ButtonColor::Primary, ButtonVariant::Solid, "bg-primary"),
(ButtonColor::Secondary, ButtonVariant::Solid, "bg-secondary"),
(
ButtonColor::Destructive,
ButtonVariant::Solid,
"bg-destructive",
),
(
ButtonColor::Primary,
ButtonVariant::Outline,
"border-primary",
),
(
ButtonColor::Secondary,
ButtonVariant::Outline,
"border-input",
),
(
ButtonColor::Destructive,
ButtonVariant::Outline,
"border-destructive",
),
(
ButtonColor::Primary,
ButtonVariant::Ghost,
"hover:bg-primary/10",
),
(
ButtonColor::Secondary,
ButtonVariant::Ghost,
"hover:bg-accent",
),
(
ButtonColor::Destructive,
ButtonVariant::Ghost,
"hover:bg-destructive/10",
),
(ButtonColor::Primary, ButtonVariant::Link, "text-primary"),
(
ButtonColor::Secondary,
ButtonVariant::Link,
"text-foreground",
),
(
ButtonColor::Destructive,
ButtonVariant::Link,
"text-destructive",
),
];
for (color, variant, expected_class) in cases {
let style = BUTTON_RECIPE.resolve(ButtonRecipeProps {
color: Some(color),
variant: Some(variant),
..ButtonRecipeProps::default()
});
assert_eq!(style.color(), color);
assert_eq!(style.variant(), variant);
assert!(style
.appearance
.split_ascii_whitespace()
.any(|class| class == expected_class));
let (legacy_classes, count) =
BUTTON_RECIPE.classes(None, Some(color), Some(variant), None);
assert_eq!(legacy_classes[count - 1], style.appearance);
}
}
#[test]
fn legacy_classes_delegates_to_compound_resolver() {
let (classes, count) = button_recipe().classes(
Some(ButtonSize::Sm),
Some(ButtonColor::Destructive),
Some(ButtonVariant::Outline),
Some(ButtonRadius::Lg),
);
assert_eq!(count, ButtonRecipe::RESOLVED_CLASS_COUNT);
assert_eq!(ButtonRecipe::CLASS_CAPACITY, 5);
assert_eq!(classes[0], base::BUTTON_BASE);
assert_eq!(classes[1], ButtonSize::Sm.classes());
assert_eq!(classes[2], ButtonRadius::Lg.classes());
assert_eq!(classes[3], ButtonColor::Destructive.outline_classes());
assert_eq!(classes[4], "");
assert!(classes[3].contains("border-destructive"));
assert!(!classes[3]
.split_ascii_whitespace()
.any(|class| class == "bg-destructive"));
}
#[test]
fn recipe_resolution_is_fully_const() {
const STYLE: ButtonStyle = BUTTON_RECIPE.resolve(ButtonRecipeProps {
size: Some(ButtonSize::Lg),
color: Some(ButtonColor::Destructive),
variant: Some(ButtonVariant::Outline),
radius: Some(ButtonRadius::Full),
});
const CLASSES: ([&str; ButtonRecipe::CLASS_CAPACITY], usize) = BUTTON_RECIPE.classes(
Some(ButtonSize::Lg),
Some(ButtonColor::Destructive),
Some(ButtonVariant::Outline),
Some(ButtonRadius::Full),
);
assert_eq!(STYLE.size(), ButtonSize::Lg);
assert_eq!(STYLE.color(), ButtonColor::Destructive);
assert_eq!(STYLE.variant(), ButtonVariant::Outline);
assert_eq!(STYLE.radius(), ButtonRadius::Full);
assert_eq!(CLASSES.0[3], ButtonColor::Destructive.outline_classes());
}
#[test]
fn variant_spec_trait_implemented() {
use crate::variant_spec::VariantSpec;
// Verify ButtonSize implements VariantSpec
assert_eq!(ButtonSize::NAME, "size");
assert_eq!(ButtonSize::TS_TYPE, "ButtonSize");
assert_eq!(ButtonSize::OPTIONS.len(), 6); // Sm, Md, Lg, Icon, IconSm, IconLg
// Verify the default variant
let default = ButtonSize::default();
assert_eq!(default.classes(), ButtonSize::Md.classes());
// Verify OPTIONS contains correct data
let sm_option = &ButtonSize::OPTIONS[0];
assert_eq!(sm_option.label, "Sm");
assert_eq!(sm_option.value, "sm");
assert_eq!(sm_option.classes, ButtonSize::Sm.classes());
// Verify ButtonColor implements VariantSpec
assert_eq!(ButtonColor::NAME, "color");
assert_eq!(ButtonColor::TS_TYPE, "ButtonColor");
assert_eq!(ButtonColor::OPTIONS.len(), 3); // Primary, Secondary, Destructive
// Verify ButtonVariant implements VariantSpec
assert_eq!(ButtonVariant::NAME, "variant");
assert_eq!(ButtonVariant::TS_TYPE, "ButtonVariant");
assert_eq!(ButtonVariant::OPTIONS.len(), 4); // Solid, Outline, Ghost, Link
// Verify ButtonRadius implements VariantSpec
assert_eq!(ButtonRadius::NAME, "radius");
assert_eq!(ButtonRadius::TS_TYPE, "ButtonRadius");
assert_eq!(ButtonRadius::OPTIONS.len(), 5);
}
#[test]
fn component_spec_exports_every_compound_without_axis_projection() {
use crate::variant_spec::ComponentSpec;
let variants = BUTTON_RECIPE.variant_metadata();
let color = variants
.iter()
.find(|metadata| metadata.name == "color")
.expect("color metadata");
let variant = variants
.iter()
.find(|metadata| metadata.name == "variant")
.expect("variant metadata");
assert!(color.options.iter().all(|option| option.classes.is_empty()));
assert!(variant
.options
.iter()
.all(|option| option.classes.is_empty()));
let compounds = BUTTON_RECIPE.compound_variant_metadata();
assert_eq!(compounds.len(), 12);
for color in [
ButtonColor::Primary,
ButtonColor::Secondary,
ButtonColor::Destructive,
] {
for variant in [
ButtonVariant::Solid,
ButtonVariant::Outline,
ButtonVariant::Ghost,
ButtonVariant::Link,
] {
let compound = compounds
.iter()
.find(|compound| {
compound.conditions.as_slice()
== [
crate::variant_spec::CompoundVariantConditionMetadata::new(
"color",
color.as_str(),
),
crate::variant_spec::CompoundVariantConditionMetadata::new(
"variant",
variant.as_str(),
),
]
})
.expect("compound metadata");
assert_eq!(compound.classes, color.classes_for(variant));
}
}
}
#[cfg(feature = "metadata-export")]
#[test]
fn exported_metadata_serializes_compound_variants() {
let json = serde_json::to_value(export_metadata()).expect("serialize component metadata");
assert!(json.get("compound_variants").is_none());
let compounds = json["compoundVariants"]
.as_array()
.expect("serialized compoundVariants");
assert_eq!(compounds.len(), 12);
let primary_outline = compounds
.iter()
.find(|compound| {
compound["conditions"][0]["value"] == "primary"
&& compound["conditions"][1]["value"] == "outline"
})
.expect("primary outline metadata");
assert_eq!(primary_outline["conditions"][0]["name"], "color");
assert_eq!(primary_outline["conditions"][1]["name"], "variant");
assert_eq!(
primary_outline["classes"],
ButtonColor::Primary.outline_classes()
);
}
}