Skip to content

Ratatui Kit

RATATUI KIT

Write terminal UIs as composable apps

Components, hooks, input layers, routing and global state — the React-style model you already know, built on top of Ratatui and Tokio.

cargo run --example todo_app
Recording of a todo app built with ratatui-kit: add/remove tasks, filter and toggle status

Quick start

The same counter, half the code

plain ratatui · manual

You do everything yourself

Enter and exit the terminal, hand-write the render loop, keep a timer with Instant, poll and dispatch every key — before the counter even starts, boilerplate already fills main.

ratatui-kit · automatic

Just describe state and UI

use_state holds state, use_future runs async, element! declares the UI. Terminal lifecycle, render loop and exit are handled by the runtime — the same counter, half the code.

main.rs · plain ratatui
fn main() -> std::io::Result<()> {
    let mut terminal = ratatui::init(); // enter / exit the terminal by hand
    let mut count = 0_u64;              // state scattered across main
    let mut last = Instant::now();      // keep your own timer
    loop {                             // hand-written render loop
        terminal.draw(|f| ui(f, count))?;
        if last.elapsed() >= Duration::from_secs(1) {
            count += 1;                // advance state manually
            last = Instant::now();
        }
        if event::poll(Duration::from_millis(50))? {
            if let Event::Key(k) = event::read()? {
                if k.code == KeyCode::Char('q') {
                    break;             // handle exit yourself
                }
            }
        }
    }
    ratatui::restore();               // don't forget to restore the terminal
    Ok(())
}
#[component]
fn Counter(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let mut count = hooks.use_state(|| 0_u64);

    hooks.use_future(async move {
        loop {
            tokio::time::sleep(Duration::from_secs(1)).await;
            count += 1;
        }
    });

    element!(
        Border(border_style: Style::new().yellow()) {
            Text(text: Line::from(format!("Counter: {:02}", count.get())))
        }
    )
}

From an empty directory to a moving terminal — three steps.

  1. Add the dependency

    Add ratatui-kit and Tokio to Cargo.toml. The framework enables no feature by default — turn on router, atom, input and so on as needed.

  2. Write your first component

    use_state holds state, use_future runs async, element! declares UI. Component instances are retained across frames by the runtime, and writing state wakes rendering automatically.

  3. Run it

    cargo run, and the terminal goes fullscreen. The counter increments every second — press q to quit, Ctrl+C to force-stop, and the terminal restores on exit.

Cargo.toml
[dependencies]
ratatui-kit = "0.10.2"
tokio = { version = "1", features = [
    "rt-multi-thread", "macros", "time",
] }
src/main.rs
use ratatui_kit::prelude::*;

#[tokio::main]
async fn main() {
    element!(Counter).fullscreen().await.unwrap();
}

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

    hooks.use_future(async move {
        loop {
            tokio::time::sleep(Duration::from_secs(1)).await;
            count += 1;
        }
    });

    element!(
        Border(border_style: Style::new().yellow()) {
            Text(text: Line::from(format!("Counter: {:02}", count.get())))
        }
    )
}
cargo run --example counter
Recording of the counter example incrementing every second, press q to quit

Write terminal UIs like a frontend app

Ratatui Kit doesn't replace Ratatui — it adds component updates, state management and event routing on top, so even large terminal apps stay clearly structured.

Declarative syntax

A JSX-style proc-macro — describe UI with data instead of hand-written draw calls. A syntax you already know, compiling component trees into terminal layout.

  • if / for / match are first-class inside child blocks
  • { expr } embeds any Rust expression returning an Element
  • widget() / stateful() bridge native ratatui widgets

Hooks & state

use_state, use_future, use_effect and custom hooks are organized by call order. State handles are Copy, support operator overloading like count += 1, and release automatically when the component unmounts.

Input-layer mutex

Events are no longer broadcast to every component — they route to the active input layer. Request a layer when opening a modal or entering edit mode, and the background list yields the keyboard, killing focus contention at the root.

Reactive rendering

State writes wake the render loop, so the framework only repaints on real change instead of imperatively redrawing. generational-box-based reactive handles let a terminal UI update on demand, like a frontend framework.

Theming

A shared Palette is the single color source — every component derives its styles from it. Swap the palette to re-theme the whole tree, override one component type, or drive it from an Atom to re-theme at runtime.

Built-in components

Layout, input, overlays, selection and virtual lists work out of the box — business-neutral and composable.

Routing & global state

Router manages multiple pages and a history stack; Atom manages business state shared across pages.

Events route by layer, not broadcast

When you open a modal or enter edit mode, the framework hands the keyboard to the active layer and background components yield input automatically. These few lines are exactly what examples/input/input_mutex.rs does.

Read the input-layer reference
input_mutex.rs
// Request an input layer when entering edit mode
let edit_layer = hooks.use_input_layer(editing.get(), true);

// The handler only receives key events while this layer is active
hooks.use_event_handler(
    EventScope::Layer(edit_layer),
    EventPriority::High,
    move |event| {
        let Event::Key(key) = event else {
            return EventResult::Ignored;
        };
        match key.code {
            KeyCode::Enter => editing.set(false),
            KeyCode::Char(c) => draft.write().push(c),
            _ => {}
        }
        // The background list never sees these keys
        EventResult::Consumed
    },
);
cargo run --example input_mutex
Recording where a modal takes over key events and the background list stops responding to input

Most components you need are already built in

From layout primitives to virtual lists, 14 built-in components stay business-neutral, composable and clear about input semantics. Scroll down to browse them one by one — each has a runnable example and a real recording.

01 / 14

View · Border · Text

The most basic layout and text primitives: flex containers, bordered grouping, paragraph rendering — slicing the component tree into predictable terminal regions.

View View · Border · Text docs
02 / 14

ScrollView

Scrollable container: content beyond the viewport renders into a larger buffer first, then displays by offset — supports j/k, paging and a scrollbar.

View ScrollView docs
03 / 14

WrappedText

Long body text: hard-wraps at a given width, then writes the real line count back into layout height so the outer ScrollView computes the scroll range correctly.

View WrappedText docs
04 / 14

Input

Display component for a single-line input box, rendering tui_input state; writing, submitting and exiting are all driven by the page handler.

View Input docs
05 / 14

SearchInput

Search box with an edit mode: press s to enter input and open an exclusive layer — wraps activation, submission, clearing, validation and input mutex.

View SearchInput docs
06 / 14

Modal

Low-level overlay: draws the backdrop, positions content and provides the current input layer to its subtree — confirm and close logic are composed on top.

View Modal docs
07 / 14

ConfirmModal

Confirmation dialog: builds an exclusive input layer internally, wrapping confirm/cancel, button focus switching and input mutex — controlled by open.

View ConfirmModal docs
08 / 14

AlertModal

Notice dialog: no confirm/cancel buttons, just shows a message and fires on_close when the close key is pressed, intercepting lower-layer input meanwhile.

View AlertModal docs
09 / 14

ShortcutInfoModal

Shortcut help dialog: shows shortcuts grouped by section; when content overflows it scrolls via an internal ScrollView while keeping the modal input mutex.

View ShortcutInfoModal docs
10 / 14

Select

Single-select list: holds a ListState internally, turning j/k, arrow keys, Home/End and Enter into list navigation and a select callback.

View Select docs
11 / 14

MultiSelect

Multi-select list: interaction has two levels — Space toggles the draft selection (fires on_change), Enter commits the selection (fires on_select).

View MultiSelect docs
12 / 14

TreeSelect

Tree selection (tree feature): reuses tui-tree-widget for hierarchical navigation, collapse/expand, path-style selection and input mutex.

View TreeSelect docs
13 / 14

VirtualList

Long list (virtual-list feature): reuses tui-widget-list to render only the visible window — handles tens of thousands of rows with custom row rendering.

View VirtualList docs
14 / 14

VirtualMultiSelect

Composition for multi-selecting a long list: VirtualList manages the cursor and windowed rendering, while business code keeps the selection set in an external HashSet.

View VirtualMultiSelect docs
View all component docs
hello_world.rs
View(flex_direction: Direction::Vertical, gap: 1) {
    Text(text: "header")
    View(height: Constraint::Fill(1)) {
        Text(text: "body")
    }
    Text(text: "footer")
}
scrollview.rs
ScrollView(
    flex_direction: Direction::Vertical,
    block: Block::bordered(),
) {
    for (i, row) in rows.iter().enumerate() {
        View(key: i, height: Constraint::Length(1)) {
            Text(text: row)
        }
    }
}
cargo run --example scrollview
ScrollView component recording
wrapped_text.rs
ScrollView(scroll_view_state: scroll_state, block: Block::bordered()) {
    WrappedText(
        text: BODY,
        wrap_width: 72,
        style: Style::new().white(),
    )
}
cargo run --example wrapped_text
WrappedText component recording
input.rs
Border(height: Constraint::Length(3)) {
    Input(
        input: input.read().clone(),
        placeholder: "Type and press Enter".to_string(),
        cursor_style: Style::new().bg(Color::Yellow),
    )
}
cargo run --example input
Input component recording
search_input.rs
SearchInput(
    value: query.read().to_string(),
    placeholder: "Press s to search".to_string(),
    on_change: move |next: String| query.set(next),
    on_submit: move |value: String| {
        submitted.set(value);
        true
    },
)
cargo run --example search_input
SearchInput component recording
modal.rs
Modal(
    open: open.get(),
    width: Constraint::Length(68),
    height: Constraint::Length(12),
) {
    Border(top_title: Line::from("Details").centered()) {
        Text(text: Line::from("Modal content"))
    }
}
cargo run --example modal
Modal component recording
confirm_modal.rs
ConfirmModal(
    open: confirm_open.get(),
    title: Line::from("Delete release?"),
    content: format!("Remove {selected_label}?"),
    confirm_text: "Delete".to_string(),
    on_confirm: move |_: ()| confirm_open.set(false),
    on_cancel: move |_: ()| confirm_open.set(false),
)
cargo run --example confirm_modal
ConfirmModal component recording
alert_modal.rs
AlertModal(
    open: alert_open.get(),
    title: Line::from("Workspace is current"),
    message: format!("{selected_label} is synced."),
    on_close: move |_: ()| alert_open.set(false),
)
cargo run --example alert_modal
AlertModal component recording
shortcut_info_modal.rs
ShortcutInfoModal(
    open: shortcuts_open.get(),
    title: Line::from("Shortcut reference"),
    sections: vec![ShortcutInfoSection::new(
        "Navigation",
        [("Down", "j"), ("Up", "k")],
    )],
    on_close: move |_: ()| shortcuts_open.set(false),
)
cargo run --example shortcut_info_modal
ShortcutInfoModal component recording
select.rs
Select<&'static str>(
    items: items,
    default_index: Some(1),
    highlight_symbol: "> ",
    empty_message: "No environments",
    on_select: move |item: &'static str| {
        selected.set(item);
    },
)
cargo run --example select
Select component recording
multi_select.rs
MultiSelect<&'static str>(
    items: items,
    highlight_symbol: "> ",
    on_change: move |items: Vec<&'static str>| {
        selected_count.set(items.len());
    },
    on_select: move |items: Vec<&'static str>| {
        submitted.set(items.len());
    },
)
cargo run --example multi_select
MultiSelect component recording
tree_select.rs
TreeSelect<&'static str>(
    active: true,
    items: demo_items(),
    default_selection: vec!["components", "select"],
    node_open_symbol: "- ",
    node_closed_symbol: "+ ",
    on_select: move |id: &'static str| {
        submitted.set(id);
    },
)
cargo run --example tree_select
TreeSelect component recording
virtual_list.rs
VirtualList<Line<'static>>(
    item_count: 10_000,
    default_index: Some(42),
    render_item: |ctx: &ListBuildContext| {
        let style = if ctx.is_selected {
            Style::new().black().on_green()
        } else {
            Style::new()
        };
        (Line::styled(format!("row {:05}", ctx.index + 1), style), 1u16)
    },
)
cargo run --example virtual_list
VirtualList component recording
virtual_multi_select.rs
VirtualList<Line<'static>>(
    state: list_state,
    item_count: item_count,
    render_item: move |ctx: &ListBuildContext| {
        let mark = if selected.contains(&ctx.index) { "[x]" } else { "[ ]" };
        (Line::styled(format!("{mark} Row {:05}", ctx.index + 1), Style::default()), 1u16)
    },
)
cargo run --example virtual_multi_select
VirtualMultiSelect component recording

From one component to a complete app

The homepage does just one thing: take you to the right first doc, then let examples and reference material take over.

Start with a counter that moves

Write a component that increments every second, then layer on async state, input layers and routing.

cargo add ratatui-kit
Get started