Proa UI
Styling and variants
How a component's classes are built, and how to change them in source installed in your repo.
Every styled component keeps its class logic in one file: components/<name>/recipe.rs. If you want to restyle a component installed in your repo, that is the file to open.
Three things live there. The excerpts below are abridged to the shape that
matters — the real recipe.rs carries more classes, more variants, and doc
comments on each.
Class constants
tw_join! builds static Tailwind strings at compile time, so a long class list stays readable without allocating at render:
pub const BADGE_BASE: &str = tw_join!(
"inline-flex",
"items-center",
"rounded-md",
"px-2.5",
"py-0.5",
"text-xs",
"font-medium"
);
Typed variants
Variants are enums, not strings. Each maps to its classes and to a stable data attribute:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "variant")]
pub enum BadgeVariant {
#[default]
Default,
Secondary,
Destructive,
}
impl BadgeVariant {
pub const fn classes(self) -> &'static str {
match self {
Self::Default => variants::BADGE_DEFAULT,
Self::Secondary => variants::BADGE_SECONDARY,
Self::Destructive => variants::BADGE_DESTRUCTIVE,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Secondary => "secondary",
Self::Destructive => "destructive",
}
}
}
classes() feeds CSS. as_str() feeds data-variant, which gives tests and client code a stable string without callers retyping variant names.
Resolving a style
Each recipe resolves its props into a fully resolved style value. BadgeStyle
holds only a reference to the recipe plus the chosen variant, so resolving
allocates nothing:
pub const fn badge_classes(variant: BadgeVariant) -> BadgeStyle {
BADGE_RECIPE.resolve(BadgeRecipeProps {
variant: Some(variant),
})
}
The style implements AttrValue, so it writes its base and variant classes
straight into the attribute. The component renders it alongside the caller's
own classes, which come last so class: Some("mt-6") always wins:
let style = BADGE_RECIPE.resolve(BadgeRecipeProps {
variant: Some(self.variant),
});
html_sync! {
<span
data-badge
data-slot="badge"
data-variant={style.variant().as_str()}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</span>
}
Independent and compound variants
Keep an axis independent when its classes can be added on their own. If a
visual rule only makes sense for a combination—Button's color × variant
appearance, for example—resolve that rule once as a compound variant:
impl ComponentSpec for ButtonRecipe {
fn variant_metadata(&self) -> Vec<VariantMetadata> {
vec![
variant_metadata::<ButtonSize>(),
compound_axis_metadata::<ButtonColor>(),
compound_axis_metadata::<ButtonVariant>(),
]
}
fn compound_variant_metadata(&self) -> Vec<CompoundVariantMetadata> {
vec![CompoundVariantMetadata::new(
vec![
CompoundVariantConditionMetadata::new("color", "primary"),
CompoundVariantConditionMetadata::new("variant", "solid"),
],
ButtonColor::Primary.classes_for(ButtonVariant::Solid),
)]
}
}
Repeat that entry for each supported pair. The compound axes still generate typed options, but their independent class maps are empty. This prevents Rust and generated TypeScript, React, or Solid recipes from applying the same appearance twice.
ComponentSpec is also the metadata boundary for a component's stable
multipart anatomy. Metadata is validated before export for duplicate parts,
invalid defaults, unknown compound conditions, and duplicate compound rules.
Adding a variant
- Add the enum arm and its class constant in
recipe.rs. - Extend
classes()andas_str()— both are exhaustive matches, so the compiler will find every place you missed. - Add its
VariantSpecmetadata, or add the new cross-product exactly once throughcompound_variant_metadata(). - Add the state to the component's catalog page so it is visible.
- Test the rendered
data-variantvalue, not the class string. Class output is an implementation detail; the data attribute is the contract.
Shared color and spacing primitives live in crate::theme (bg::PRIMARY, text::PRIMARY_FOREGROUND). Compose recipes from those rather than hardcoding palette values, so a token change reaches every component.