Skip to content

Declarative syntax

Ratatui Kit’s everyday syntax is built on several macros:

  • element!: declare this frame’s UI tree.
  • #[component]: turn a function into a persistent Component.
  • #[derive(Props)]: let props participate in runtime type erasure.
  • #[with_layout_style]: inject layout fields into component props.
  • routes!: declare a Router route table.

This page is a syntax reference. To see how state is preserved at runtime, read the component model. To see real if / for / match examples, read control flow syntax.

The most common form is:

element!(
    Border(top_title: Line::from(" dashboard ").centered()) {
        Text(text: "ready")
        Counter
    }
)

Parentheses contain props; braces contain children. If there are no props, omit the parentheses. If there are no children, omit the braces.

Field values go through .into(), so many props accept lighter call-site syntax than their field type:

Border(top_title: Line::from("title")) // Option<Line> receives a bare Line
Text(text: "ready")                    // TextParagraph receives &str

element! fills omitted fields with Default::default(). That means custom props constructed through field syntax must implement Default.

If props cannot provide a reasonable default, construct the full props value in ordinary Rust code and pass it with ..props:

let queue_props = DeployQueueProps { state: list_state };

element!(
    DeployQueue(..queue_props)
)

..rest must be the last item in the props list. When rest props are used, the macro does not append ..Default::default().

Children can contain if, if let, for, and match directly:

element!(
    View {
        if loading.get() {
            Text(text: "loading")
        } else {
            Border {
                Text(text: "ready")
            }
        }

        for item in items.iter() {
            Text(key: item.id, text: item.title.clone())
        }

        match mode {
            Mode::Compact => { Text(text: "compact") }
            Mode::Detail => { Border { Text(text: "detail") } }
        }
    }
)

Different branches may return different component types without manually calling .into_any(). This is declaration-layer control flow; do not call hooks inside those branches. Hook call order belongs to the component instance and must be stable every frame.

More complex dynamic expressions can still be embedded with { expr }:

element!(
    View {
        { maybe_banner.map(|text| element!(Text(text: text))) }
        { rows.into_iter().map(|row| element!(Text(key: row.id, text: row.title))) }
    }
)

Prefer first-class control flow. Use { expr } only when the expression naturally returns Option, Vec, an iterator, or an element.

key: is a reserved field and does not enter props. It only affects element identity:

for task in tasks.iter() {
    TodoRow(key: task.id, task: task.clone())
}

The runtime reuses previous-frame component instances by ElementKey + TypeId. Use business IDs when list items can be inserted, deleted, or reordered. Use indices only for static arrays or small lists whose order never changes.

You cannot write key: on route components inside routes!. Route identity is determined by path, and the macro rejects this explicitly.

Function components use #[component]:

#[component]
fn Counter(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let count = hooks.use_state(|| 0);

    element!(
        Text(text: format!("count: {}", count.get()))
    )
}

The macro recognizes only these parameter names:

Parameter namePurpose
hooks / _hooksReceive Hooks, by value or by reference
props / _propsReceive a props reference; the type must be a reference

Other parameter names produce an error. This is not a style rule; the macro uses parameter names to decide what to pass to the generated implementation.

Function components are transparent layout components: they do not occupy independent layout nodes, and instead pass the returned root element to the runtime. Layout props should be written on the returned root element:

#[component]
fn Panel(_hooks: Hooks) -> impl Into<AnyElement<'static>> {
    element!(
        Border(width: Constraint::Length(40)) {
            Text(text: "content")
        }
    )
}

If you want a parent to write Panel(width: ...) and have Panel occupy its own layout slot, do not make it a no-props transparent wrapper. Give it props and explicitly forward layout fields to the internal root, or write Component by hand.

Custom props must implement Props:

#[derive(Default, Props)]
struct PanelProps {
    title: String,
}

If Default cannot be derived, write a semantically neutral default:

#[derive(Props)]
struct PanelProps {
    title: String,
    level: usize,
}

impl Default for PanelProps {
    fn default() -> Self {
        Self {
            title: String::new(),
            level: 1,
        }
    }
}

For fields with no reasonable default, such as State<ListState>, prefer constructing the complete props at the call site and passing Component(..props). Do not fabricate an invalid state handle just to satisfy Default.

Optional fields should use Option<T>. Because field values are converted with .into(), all three forms below work:

Border(top_title: Line::from("title"))
Border(top_title: Some(Line::from("title")))
Border(top_title: None)

Ratatui 0.30’s Block<'static> can be used directly as a props field, for example block: Option<Block<'static>>. Do not reintroduce Send/Sync wrappers; the current runtime is single-threaded for rendering, and the framework no longer forces props to be Send + Sync.

Hand-written components without props use NoProps:

struct Spinner;

impl Component for Spinner {
    type Props<'a> = NoProps;

    fn new(_props: &Self::Props<'_>) -> Self {
        Self
    }
}

Function components usually do not need to mention NoProps; #[component] generates the corresponding props type. Use NoProps explicitly only for hand-written Components, adapters, or test helpers.

Internally, the runtime stores different component props in AnyProps. This is a type-erased container whose correctness depends on reconciliation already having matched component type and props type. Application code should not construct or downcast AnyProps directly; expose concrete props types and let element! and the runtime handle erasure.

Built-in component callbacks such as on_change, on_select, and on_close use Handler<'a, T, V = ()>. Callers usually do not need to write Handler::from because element! calls .into() for field values:

let mut selected = hooks.use_state(String::new);

element!(
    Select(
        items: actions,
        on_select: move |action| selected.set(action),
    )
)

Custom components that expose callbacks should prefer the same type:

#[derive(Props)]
pub struct CommandProps {
    pub on_submit: Handler<'static, String, bool>,
}

impl Default for CommandProps {
    fn default() -> Self {
        Self {
            on_submit: Handler::default(),
        }
    }
}

Handler::default() is an empty implementation, and the return type V must implement Default. Inside a component, use take() before moving a callback into an event closure. Use is_default() when you need to distinguish whether the user passed a callback.

If a component should support width, height, margin, offset, gap, flex_direction, and justify_content, add #[with_layout_style] to its props:

#[with_layout_style]
#[derive(Default, Props)]
pub struct ViewProps<'a> {
    pub children: Vec<AnyElement<'a>>,
}

You can also inject only part of the layout fields:

#[with_layout_style(margin, offset, width, height)]
#[derive(Props)]
pub struct VirtualListProps<W> {
    // ...
}

#[with_layout_style] only works on named-field structs. It generates a layout_style() method, which hand-written components should call during update:

fn update(&mut self, props: &mut Self::Props<'_>, _hooks: Hooks, updater: &mut ComponentUpdater) {
    updater.set_layout_style(props.layout_style());
}

element! can bridge Ratatui widgets directly:

element!(
    Border {
        widget(paragraph)
        stateful(list, list_state)
    }
)

widget(expr) accepts exactly one expression. stateful(widget, state) accepts exactly two arguments: the widget and the state. The old $ / #(...) adapter syntax has been removed.

Adapters are good for simple bridges:

  • Native stateless widgets such as Paragraph, Line, and Block.
  • Native stateful widgets such as List and Table that need Ratatui state.

If you need to construct complex widgets inside a component, hold props, register events, or override layout, write Component by hand. See Native widget bridge for the complete example.

routes! reuses the props-head syntax from element!:

let routes = routes! {
    "/" => AppShell(title: "Workspace") {
        "/" => HomePage,
        "/projects/:slug" => ProjectPage,
    },
};

Parentheses () are route component props; braces {} are child routes. Props passed through routes! enter a 'static route table and cannot borrow stack temporaries.

Do not put dynamic data into route props:

  • Dynamic path segments come from use_params().
  • Lightweight context for one navigation comes from push_with_state / try_use_route_state::<T>().
  • Cross-page business state belongs in Atom or an outer Provider.

See Routing for more route boundaries.