---
title: Components
description: How Proa components are plain structs, how props and children flow, and how the recipe system styles them.
keywords: [WebRenderSync, WebRender, to_async, await_component, ProaComponent]
---

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

<Callout>

You do not have to write the common ones yourself. [proa-ui](https://ui.proa.so/docs) 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.

</Callout>


```rust filename="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:

```rust filename="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:

```rust filename="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:

```rust
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:

```rust filename="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:

```rust filename="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`](/docs/core/class-composition) rather than a `String`:

```rust filename="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:

```html
<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:

- **`data-button`** marks the component root. Every component renders a bare `data-<name>` attribute so tests and browser automation can select it without depending on class strings.
- **`data-variant` / `data-size`** publish the resolved variant. Styling reads from `class`, but assertions, visual regression tooling, and CSS overrides read the semantic value.

## 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:

```rust filename="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](/docs/core/html-rendering) and [Markdown rendering](/docs/core/markdown) 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(...)`:

```rust filename="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](https://ui.proa.so/docs) 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:

```rust filename="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

- [HTML rendering](/docs/core/html-rendering)
  - Elements, attributes, control flow, and the sync and async render traits.
- [Class composition](/docs/core/class-composition)
  - `ClassList`, capacity, and the allocation to avoid in a render path.
- [Markdown rendering](/docs/core/markdown)
  - The `md_sync!` and `md!` template macros behind an `MdRenderSync` impl.
- [Islands: when and why](/docs/guides/islands)
  - Add browser behavior to a component with RSJS.
