Docs

Components

How Proa components are plain structs, how props and children flow, and how the recipe system styles them.

Open Markdown

A Proa component is a struct plus a render impl. Its fields are props, its impl defines the template, and Rust checks both.

You do not have to write the common ones yourself. proa-ui is a shadcn-style catalog of Proa components, and proa ui add button vendors the source into src/components/button/ in your project. You own the files and edit them exactly like the ones on this page.

src/components/button/button.rs
use super::recipe::{ButtonSize, ButtonVariant};

pub struct Button<C = ()> {
    pub variant: ButtonVariant,
    pub size: ButtonSize,
    pub disabled: bool,
    pub class: Option<&'static str>,
    pub children: Option<C>,
}

impl<C> Default for Button<C> {
    fn default() -> Self {
        Self {
            variant: ButtonVariant::Solid,
            size: ButtonSize::Md,
            disabled: false,
            class: None,
            children: None,
        }
    }
}

That declaration is the component's public API. Every field is a prop, rustdoc documents it for free, and passing an unknown one is a compile error rather than a silently ignored attribute.

Rendering a component

Put a struct literal in braces inside a template and it renders in place:

src/pages/home.rs
html_sync! {
    <div class="flex gap-2">
        {Button { children: Some("Save"), ..Button::default() }}
        {Button {
            variant: ButtonVariant::Outline,
            children: Some("Cancel"),
            ..Button::default()
        }}
    </div>
}

Struct-literal syntax is doing real work here. ..Button::default() is Rust's functional update, so a component with twelve props stays readable when a caller sets two, and the defaults live in one Default impl instead of being scattered across call sites.

There is no separate props type to keep in sync. The literal is constructed on the stack and consumed by render.

Writing the render impl

The impl decides which trait the component supports. Use WebRenderSync unless its body or metadata hook needs to await work:

src/components/button/button.rs
use proa::{DataLoader, WebContext, WebRenderSync, WriteError};
use proa::html_sync;
use super::recipe::button_classes;

impl<L, C> WebRenderSync<L> for Button<C>
where
    L: DataLoader,
    C: WebRenderSync<L>,
{
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            <button
                data-button
                data-variant={self.variant.as_str()}
                data-size={self.size.as_str()}
                type="button"
                class={button_classes(self.variant, self.size, self.class)}
                disabled={self.disabled}
            >
                {self.children}
            </button>
        }
        .render(cx)
    }
}

render takes self by value, so props move into the template with no clone and no borrow to satisfy.

Children

A component that wraps caller content is generic over its children, and the bound is the same render trait the parent implements:

impl<L, C> WebRenderSync<L> for Button<C>
where
    C: WebRenderSync<L>,

C = () supplies the default child type when you write a type such as let button: Button = Button::default(). A child like Some("Save") infers its own C; for an otherwise unconstrained empty button, write Button::<()>::default(). The manual Default implementation does not require C: Default.

The render bound constrains only types actually rendered as children.

Children are Option<C> because Option<T> renders nothing when None. Optional content needs no branch in the template:

src/components/panel.rs
html_sync! {
    <section data-panel>{self.subtitle}{self.children}</section>
}

Styling: recipes, not class strings

Proa is opinionated about where classes live. A component does not build a class string at render time; it selects between static fragments that were resolved at compile time. For reusable components, keep style decisions in recipe.rs beside the render implementation.

recipe.rs owns the styling. Each variant axis is an enum whose classes() maps a semantic name to a class fragment:

src/components/button/recipe.rs
use proa::ClassList;

mod base {
    pub const BUTTON_BASE: &str =
        "inline-flex items-center justify-center rounded-md text-sm font-medium";
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonVariant {
    #[default]
    Solid,
    Outline,
    Ghost,
}

impl ButtonVariant {
    pub const fn classes(self) -> &'static str {
        match self {
            Self::Solid => "bg-primary text-primary-foreground hover:bg-primary/90",
            Self::Outline => "border border-input bg-background hover:bg-accent",
            Self::Ghost => "hover:bg-accent hover:text-accent-foreground",
        }
    }

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Solid => "solid",
            Self::Outline => "outline",
            Self::Ghost => "ghost",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonSize {
    #[default]
    Md,
    Sm,
}

impl ButtonSize {
    pub const fn classes(self) -> &'static str {
        match self {
            Self::Md => "h-9 px-4",
            Self::Sm => "h-8 px-3",
        }
    }

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Md => "md",
            Self::Sm => "sm",
        }
    }
}

Both methods support const evaluation. At render time, a variant selects an existing &'static str fragment without constructing a class string. The paired as_str is what the component renders into data-variant.

One function composes the axes, returning a stack-allocated ClassList rather than a String:

src/components/button/recipe.rs
pub fn button_classes(
    variant: ButtonVariant,
    size: ButtonSize,
    class: Option<&'static str>,
) -> ClassList<4> {
    ClassList::new()
        .add(base::BUTTON_BASE)
        .add(variant.classes())
        .add(size.classes())
        .add_opt(class)
}

add_opt is the escape hatch: callers extend a component with class: Some("w-full") without the component owning every combination.

Rendering Button { children: Some("Save"), ..Button::default() } produces:

<button data-button data-variant="solid" data-size="md" type="button"
        class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4">Save</button>

Two conventions show up in that output and both are deliberate:

One component, multiple render targets

Rendering is a trait implemented by a component's Rust type. Components share data and typed children across HTML, Markdown, and other render targets, while each implementation defines its output. HTML uses WebRenderSync and Markdown uses MdRenderSync; other targets can define their own rendering traits. A target can produce a document format or use a different rendering backend.

The following example gives a button a Markdown implementation alongside its HTML implementation.

Most interactive chrome has no Markdown equivalent, so the Markdown impl usually renders the meaning rather than the widget. A button is its label:

src/components/button/md.rs
use proa::{DataLoader, MdRenderSync, WebContext, WriteError};
use super::Button;

/// Button renders as just its label text; the visual chrome is meaningless in Markdown.
impl<L: DataLoader, C: MdRenderSync<L>> MdRenderSync<L> for Button<C> {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        proa::md_sync! { "{self.children}" }.render_md(cx)
    }
}

The ordinary-string template selects inline mode while its children render and restores the enclosing mode on success, error, or panic unwinding. Block templates are rejected inside that scope, including through typed children. Give a block component an inline representation when it needs to appear in a label or table cell.

A physically multiline raw template selects block mode and inserts only the missing paragraph-boundary bytes before its body and after successful rendering. The hash count only delimits the literal. Single-line raw literals are inline. The macro body is always one string literal; to render components in sequence without authoring Markdown, use a tuple such as (overview, impact).render_md(cx). Lower-level renderers can still call cx.md_block_boundary() explicitly.

Good to know: Target implementations are independent. A component can support one target or several. For HTML, use WebRenderSync by default and WebRender when rendering needs to .await; Markdown has the same split with MdRenderSync and MdRender. See HTML rendering and Markdown rendering for those built-in traits and macros.

Crossing the sync and async boundary

A sync-only component composes into an async tree through to_async(...):

src/pages/dashboard.rs
html! {
    <main>{proa::to_async(Button { children: Some("Save"), ..Button::default() })}</main>
}

When a future resolves to a renderable child, use await_component(...) instead. An async child cannot appear inside html_sync! at all: move the boundary up to WebRender and html!, or await the data before rendering.

How proa-ui scales this

The proa-ui component library runs the same model with two additions that matter once a component has several independent style axes.

A resolver instead of a helper function. Props arrive as Option, and a const recipe resolves them against defaults into one Copy style value:

proa_ui/src/components/button/recipe.rs
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),
};

BUTTON_RECIPE.resolve(props) returns a ButtonStyle that implements AttrValue, so it streams its class fragments straight into the response buffer with no intermediate ClassList or String. It also carries the effective values, which is what feeds data-size, data-color, data-variant, and data-radius.

Compound variants. Independent axes concatenate, but color x variant does not: an outline destructive button is not "destructive classes plus outline classes". proa-ui resolves that pair in one place, color.classes_for(variant), rather than layering two fragments and hoping the cascade agrees.

The library also derives #[derive(ProaVariant)] on its variant enums to publish variant metadata (labels, values, classes) for its catalog and TypeScript codegen. That derive expands to an impl of proa-ui's own VariantSpec trait, so it belongs to the library's infrastructure rather than to application components; a component in your app uses plain enums exactly as shown above.

Next steps

Search

Type at least 2 characters