Proa UI

Styling and variants

How a component's classes are built, and how to change them in source installed in your repo.

Open Markdown

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

  1. Add the enum arm and its class constant in recipe.rs.
  2. Extend classes() and as_str() — both are exhaustive matches, so the compiler will find every place you missed.
  3. Add its VariantSpec metadata, or add the new cross-product exactly once through compound_variant_metadata().
  4. Add the state to the component's catalog page so it is visible.
  5. Test the rendered data-variant value, 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.

Search

Type at least 2 characters