ratatui-kit
Build polished, component-driven terminal UIs (TUIs) in Rust with the ratatui-kit framework — React-style components, hooks, props, the element! macro, routing, and global state, on top of Ratatui + T
Files8
View on GitHubSKILL.mdskills/ratatui-kit/SKILL.md
GitHubratatui-kit — Building Terminal UIs in Rust
ratatui-kit is a component framework for
terminal UIs, built on Ratatui and Tokio.
If you know React, the mental model transfers almost 1:1: element! is JSX,
#[component] is a function component, use_state is useState, use_future is
an async effect, props and context work the same way, and there is a built-in
router and global store.
This framework is niche — do not generate its code from memory. The macro
syntax, component props, hook signatures, and the input system are specific and
easy to get subtly wrong. Use the minimal template below plus the reference files
in references/ as your source of truth. A wrong prop name or a hook in the wrong
place is a hard compile error, not a warning.
Companion skills. This skill covers the framework and assumes general Rust
competence — it does not re-teach ownership, mut bindings, borrow rules, or async.
Install rust-best-practices and rust-async-patterns alongside it; they
catch the general-Rust mistakes (e.g. a missing mut on a mutated binding) that
aren't specific to ratatui-kit. And whatever else you do, compile before you call
it done (see Verifying) — cargo check names every such trivial error precisely.
The build workflow
When asked to build, scaffold, or extend a ratatui-kit UI, work in this order:
Establish the project.
- New project:
cargo new <name>, then add deps (see Project setup below). Decide which features you need — components are feature-gated and won't compile if the feature is off. When in doubt, start withfeatures = ["full"]. - Existing project: check
Cargo.tomlforratatui-kitand which features are enabled before using a gated component.
- New project:
Plan the component tree. Sketch the screens and panels as
#[component]functions. Decide state ownership: ephemeral, per-component state →use_state; state shared across distant components or pages → anAtom(atomfeature). Multi-screen app →RouterProvider+routes!.Compose with
element!. Build each component's tree. Remember function components use transparent layout: put layout props (width,height,flex_direction,gap, …) on the root element you return, not on the call site of a function component.Wire input and lifecycle. Register keyboard handlers with
use_event_handler; use semantic pointer hooks for mouse interactions. Get the quit callback fromuse_exit. Ctrl+C exits through the framework by default; applications that need cancellation or double-press exit can explicitly route it through the central event dispatcher.Verify by compiling — this is the definition of done. Run
cargo checkwith the features you used, read the errors, and fix them; iterate check → fix → check until clean. Most first-compile failures are trivial and the compiler names them precisely (a missingmutbinding, a wrong prop name, an un-enabled feature). See Verifying at the end. Examples in the repo (examples/) are runnable references for any pattern.
For anything beyond the essentials on this page, open the matching reference:
| You're working on… | Read |
|---|---|
| Which component to use, its exact props/types | references/components.md |
Hook signatures, State/AtomState methods & operators |
references/hooks.md |
element! grammar, #[component], #[derive(Props)], routes!, adapters |
references/syntax-and-macros.md |
| Click / hover / drag, pointer capture, Button, mouse setup | references/pointer-events.md |
| Keyboard events, input layers, state, routing | references/events-state-routing.md |
| App skeleton, layout & visual polish, interactive patterns, pitfalls | references/building-polished-uis.md |
Every Palette/*Theme field, custom ComponentTheme, preset themes |
references/theming.md |
Project setup
ratatui-kit ships zero features by default; enable what you use. An async
runtime is required — the docs and examples use Tokio.
[dependencies]
ratatui-kit = { version = "0.12", features = ["full"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
New crate: set
edition = "2024"in[package](the crate is edition 2024, so an older edition is rejected). If the crate sits inside or beside another Cargo workspace, add an empty[workspace]table to itsCargo.tomlso Cargo doesn't attach it to the enclosing workspace.
| Feature | Unlocks |
|---|---|
router |
RouterProvider / Outlet, routes!, use_navigate / use_route / use_params |
atom |
Atom / AtomState / use_atom (process-global state) |
input |
Input / SearchInput (single-line text entry) |
tree |
TreeSelect |
table |
Table (data grid: cell-grid borders, wrapping, row/column highlight, footer) |
virtual-list |
VirtualList (windowed list for huge data) |
textarea |
TextArea (multi-line text entry with soft wrap) |
full |
all of the above |
Core components need no feature: View, Border, Center, Fragment,
Text, WrappedText, Positioned, Modal, ConfirmModal, AlertModal,
ShortcutInfoModal, Button, Select, MultiSelect, ScrollView, ContextProvider.
TextAreais a full redesign ontui-input(not tui-textarea): soft-wrapped display rows, display-row Up/Down/Home/End, click-to-position (cursor stops before the clicked cell),on_submitfires on Enter in single-line mode. Value + cursor are caller-owned: feedvalueback throughon_change.
Standard imports — prelude::* for framework items, ratatui:: for styles/layout
types, crossterm::event:: for key types:
use ratatui_kit::{
crossterm::event::{Event, KeyCode, KeyEventKind},
prelude::*,
ratatui::{
layout::{Constraint, Direction, Flex},
style::{Color, Style, Stylize},
text::Line,
},
};
When you build multi-span styled lines, import Span too (text::{Line, Span})
rather than writing the fully-qualified path: Line::from(vec![Span::styled("label ", Style::new().dark_gray()), Span::styled(value, Style::new().yellow())]).
Minimal app (copy this as your starting point)
This compiles and runs as-is. It shows the five things every app needs: an entry point, a component, local reactive state, an async side effect, and a keyboard handler with a quit key.
use ratatui_kit::{
crossterm::event::{Event, KeyCode, KeyEventKind},
prelude::*,
ratatui::{
layout::{Constraint, Direction, Flex},
style::{Style, Stylize},
text::Line,
},
};
#[tokio::main]
async fn main() {
element!(App)
.fullscreen() // take over the whole terminal (alt screen)
.await
.expect("Failed to run the application");
}
#[component]
fn App(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let mut count = hooks.use_state(|| 0_u64);
let mut exit = hooks.use_exit();
// Async side effect: writing state wakes the render loop — no manual redraw.
hooks.use_future(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
count += 1; // State<T> overloads += and notifies on write
}
});
// Standard keyboard-handler shape: destructure Key → check Press → match code.
hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
let Event::Key(key) = event else { return EventResult::Ignored };
if key.kind != KeyEventKind::Press { return EventResult::Ignored }
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => { exit(); EventResult::Consumed }
_ => EventResult::Ignored,
}
});
element!(
Center(width: Constraint::Length(48), height: Constraint::Length(9)) {
Border(
flex_direction: Direction::Vertical,
justify_content: Flex::Center,
border_style: Style::new().cyan(),
top_title: Line::from(" ratatui-kit ").cyan().bold().centered(),
bottom_title: Line::from(" q quit · Ctrl+C exit ").dark_gray().centered(),
) {
Text(text: Line::styled(
format!("Counter: {:02}", count.get()),
Style::new().green().bold(),
).centered())
}
}
)
}
There is also a non-fullscreen mode if you want the UI to share the scrollback,
but .fullscreen().await is the default for an app.
Mental model (the parts that change the code you write)
Declarative & re-created each frame.
element!produces a lightweight tree description; the framework reconciles it against the live component tree. You describe what the UI should be, never imperatively draw.Reactive, not imperative. The UI only re-renders when reactive state changes:
use_state'sState<T>, a globalAtomState<T>, async writes, or a terminal event. A plain local variable will not trigger a redraw — anything the user should see must live inuse_stateor anAtom.#[component]+ transparent layout. A#[component] fnbecomes a component whose body runs each frame and returns one root element. Function components do not occupy their own layout node — they inherit theLayoutStyleof the root element they return. So layout props belong on that returned root, not on the tag where you use the component.Reconciliation preserves state by identity. A node is reused across frames when its
keyand component type match the previous frame — and reuse means its hooks/state survive. Give every element in aforloop a stablekey:.Hook order must be stable. Hooks are indexed by call order. Never put a hook (
use_state,use_future,use_event_handler,use_atom, …) inside anif,for,match, or earlyreturn— call them unconditionally at the top of the component body, or you get a "Hook type mismatch" panic.
element! essentials
element! is JSX-like. A node is Component(prop: value, ...) { children }. Prop
values are auto-wrapped with .into(), so text: "hi", top_title: Line::from(..)
(an Option field — auto-Some), and wrap: true all work. Omitted props fall
back to Default, so every Props type must be Default.
element!(
View(flex_direction: Direction::Vertical, gap: 1) {
Text(text: "first line")
Text(text: "second line", style: Style::new().dark_gray())
}
)
Control flow is first-class in the children block — if / if let / for /
match work directly, and different branches may return different element types:
element!(
Border(flex_direction: Direction::Vertical) {
if items.is_empty() {
Text(text: "Nothing here", style: Style::new().yellow())
} else {
for (i, item) in items.iter().enumerate() {
View(key: i, height: Constraint::Length(1)) { // stable key in loops
Text(text: item.clone())
}
}
}
}
)
{ expr } embeds any Rust expression returning an Element, Option<Element>,
Vec<AnyElement>, or impl Iterator<Item = Element>. Bridge any native Ratatui
widget with widget(expr) (stateless) or stateful(widget, state) — your escape
hatch when no built-in component fits. Full grammar: references/syntax-and-macros.md.
Handling input
The standard handler shape (used by every example) — destructure the key, gate on
Press, match the code, return Consumed when you handled the key and Ignored
otherwise so other handlers/layers still get a chance:
hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
let Event::Key(key) = event else { return EventResult::Ignored };
if key.kind != KeyEventKind::Press { return EventResult::Ignored }
match key.code {
KeyCode::Char('j') | KeyCode::Down => { /* move down */ EventResult::Consumed }
KeyCode::Char('k') | KeyCode::Up => { /* move up */ EventResult::Consumed }
KeyCode::Char('q') => { exit(); EventResult::Consumed }
_ => EventResult::Ignored,
}
});
Modals and search boxes that must capture input (so the background stops
reacting) use an input layer. Prefer the built-in ConfirmModal / AlertModal
/ ShortcutInfoModal, which manage their own layer — you only pass open + a
callback. Roll your own layer only for custom modal interactions; see
references/events-state-routing.md for use_input_layer and the layering rules.
Mouse integration. Read references/pointer-events.md
before adding pointer behavior. Enable RunOptions { mouse: true } at the entry;
these APIs are core and require no feature flag. Choose the API by ownership:
- Built-in action →
Button(left Down); release-to-activate →use_click. - Region feedback/resizing →
use_hover/use_drag; custom dispatch →use_pointer. - Third-party/custom list →
use_hover_row(hit); custom scrollable content →use_scrollbar(ScrollbarOptions, on_seek)with one content unit throughout. - ScrollView and TreeSelect scrollbars already support drag, track jump and arrow
steps. Configure
ScrollbarStyleonce, including hover and drag feedback.
Keep hooks stable when props change; guard callbacks instead. A hand-written
Component::update must attach the updater's context stack before hooks.
PointerEvent.position and area use drawing coordinates; screen_position,
raw events, Click.position, and drag positions use terminal coordinates. Let
nested ScrollViews map and clip hits. Pass an explicit same-frame
PointerOptions.layer for editing/modal handlers. Handle drag cancellation by
clearing transient feedback. For custom lists, reject loading/empty/out-of-range
hits, release read guards before writing, and write hover state only on change.
Button does not provide keyboard focus or activation; register shortcuts for the
same business action separately.
Custom Ctrl+C behavior. The default remains immediate framework exit. To
implement agent cancellation, confirmation, or double-press exit, call
SystemContext::set_auto_quit_on_ctrl_c(false) from the root component and
register a Global handler for Ctrl+C. Return Consumed after handling it so the
event does not continue into the active layer. The exact borrow-safe pattern is in
references/events-state-routing.md §1.5.
Text field that coexists with command keys. When the user types into a field
and single-letter keys (j/k/Space/q) are commands, reach for SearchInput
— its activate_key opens an exclusive input layer while editing, so background
command keys stay live and you avoid a global Editing/Navigating mode. Build a manual
two-mode state machine, or forward every key to a raw Input, only when the whole
screen is a form with no competing single-letter commands.
Shared-layer ordering. A persistent shell/parent that shares an input layer with
child pages (or a Select) must return EventResult::Ignored for every key it
doesn't own — parents are dispatched before children in the same layer, so a blanket
_ => Consumed in the shell swallows the children's j/k/Enter. Give a child
that must pre-empt the shell EventPriority::High.
State
Local:
let mut count = hooks.use_state(|| 0);→ aCopyhandle. Read withcount.get()(forCopytypes) orcount.read(); write withcount.set(v),count.write(), or operators (count += 1notifies on write). Non-Copydata:let list = hooks.use_state(Vec::new); list.write().push(x); let snap = list.read().clone();Global (
atomfeature): declare a module-levelstatic, subscribe in any component. Writes wake only the components subscribed to that atom.static SCORE: Atom<i32> = Atom::new(|| 0); #[component] fn ScoreCard(mut hooks: Hooks) -> impl Into<AnyElement<'static>> { let mut score = hooks.use_atom(&SCORE); // subscribe // score.get(), score += 1, score.set(0) — all notify subscribers element!(Text(text: Line::from(format!("score: {}", score.get())))) }
Binding mutability (framework API detail). State/AtomState are Copy handles
whose .set() / .set_no_update() and operators (+= -= *= /=) take
&mut self, while .get() / .read() / .write() take &self. So a handle you
mutate must be let mut; defaulting every handle to let mut sidesteps the most
common trivial slip (an unused mut is only a warning). The general borrow rule
behind this is rust-best-practices territory — lean on that skill and on cargo check.
Formatting a handle. The handle itself is Display/Debug (format!("{count}")
works), but the guard from .read()/.write() is not — interpolating
x.read() into a format!/Line fails (ReactiveRef doesn't implement Display).
Format the handle directly, or deref the guard: &*x.read(), x.read().clone(),
or x.read().as_str().
State and AtomState share the same methods/operators; full list in
references/hooks.md.
Visual polish (principles, not a fixed style)
"Polished" here means clear, consistent, and legible — not a specific color
scheme. The framework gives you the tools; choose a palette and density that fit
the app's identity and the user's request. The repo's own examples gravitate
toward blue/cyan frames with dark_gray hint lines — treat that as one coherent
option, not a rule.
Colors come from a theme, not scattered literals. Every component derives its
styles from a shared Palette, and the default palette already gives a coherent
scheme — you get sensible colors without setting any style prop. Recolor the whole
app by tuning a Palette inside PaletteProvider; override one spot with a bare
Style prop (component theme overrides are Option<Style>, patched over the theme; configuration structs such as ScrollbarStyle use plain Style fields);
re-style one component type with ThemeOverride::<FooTheme>(theme: ...) (turbofish
required); or drive the Palette from an Atom<Palette> to switch themes at
runtime. If the user wants a named color scheme (Dracula, Nord, Catppuccin, ...)
rather than a hand-picked one, reach for the official ratatui-kit-themes
extension crate instead of hand-tuning Palette fields — see
references/theming.md §7. For every Palette field, every built-in
component's *Theme fields, and how to make your own component theme-aware,
see references/theming.md.
Principles, and the tools that achieve them:
- Hierarchy — separate primary from secondary with color + weight + alignment.
Tools:
Stylize(.bold(),.green(),Style::new().fg(..)), reverse-video highlights (Style::new().black().on_cyan()) for the selected row. - Whitespace — let the UI breathe. Tools:
gapbetween flex children,margin/Padding, sizing panels withConstraint::Length/Fill. - Alignment & framing — fixed-width centered panels read as intentional. Tools:
Center,Borderwith a.centered()top_title,justify_content: Flex::Center. - Orientation — show users where they are and what they can press. Tools: a
bottom_titlehint line listing key → action (e.g." j/k move | Enter select | q quit "). - State feedback — distinguish loading / success / warning / error and the
focused/selected element so the UI feels responsive. Tools: status-colored
Lines,active/highlight_styleprops onSelect/MultiSelect,.dim()backgrounds behind modals.
Concrete idioms, the standard app skeleton, and interactive-component patterns are
in references/building-polished-uis.md.
Common pitfalls (each one is a real compile error or silent bug)
- Hook inside
if/for/match/early-return → "Hook type mismatch" panic. Call hooks unconditionally at the top of the component. - A non-
muthandle you later.set()/+=→ "cannot borrow as mutable" — the single most common trivial failure. Default tolet muton everyuse_state/use_atom/use_exitbinding; an unusedmutis just a warning. Select/MultiSelectwith nodefault_index→ nothing is highlighted on the first frame andEnteris a no-op until the user pressesj/k. Setdefault_index: Some(0)whenever the first interaction is Enter-to-confirm.- Using a gated component without its feature → "cannot find" errors.
Input/SearchInputneedinput;TreeSelect→tree;Table→table;VirtualList→virtual-list; router items →router;Atom→atom. Develop with--all-featuresorfull. - Layout props on a function component's call site instead of its returned root → no effect (transparent layout). Put them on the root element the component returns.
forelement withoutkey:→ reconciliation reuses the wrong node; state leaks between rows. Use the loop index or a stable id.- Expecting a redraw from a plain variable → nothing happens. Only
use_state/Atom/async writes/events wake the render loop. - Storing an
InputLayeracross frames → it's re-minted each frame; a stored handle goes deaf. Pass it within the same frame only. Blockis notSend + Syncsince Ratatui 0.30 — holdOption<Block<'static>>directly in props; don't reintroduce anySendBlockwrapper.- A
{ … }right after a self-closing component is eaten as its children, not a sibling embed.SearchInput(props) { if x { Foo } }makes the braceSearchInput's children block — which parsesif/for/matchas first-class control flow over element children, so anelement!(…)or arbitrary Rust expr inside yields a crypticexpected identifier. For a conditional sibling, use first-class control flow with native element children (no wrapping{}, no innerelement!(…)/.into_any()):SearchInput(props)thenif x { Border(…){…} } else { TreeSelect<T>(…) }. widget(w)needsfor<'a> &'a w: Widget(renders by reference). Widgets that only implWidgetby value (e.g.tui-big-text'sBigText) fail with&T: Widget is not satisfied. Use a version that implsWidgetRef(→&T: Widget), or wrap it:struct W(BigText); impl Widget for &W { fn render(self, a, b){ self.0.clone().render(a,b) } }thenwidget(W(bt)).
Verifying
Always compile before declaring the task done — a clean cargo check is the
definition of done. Build with the features you actually used (gated modules don't
compile otherwise), read any errors, fix them, and re-check until clean:
cargo check --features full # or your specific feature set
cargo clippy --all-targets --features full -- -D warnings
cargo run # run it; or `cargo run --example <name>` in the repo
Most first-compile failures are trivial and the compiler names them precisely — a
missing mut on a mutated State handle, a wrong prop name, or an un-enabled
feature. The repo has runtime, component, pointer-interaction, and macro UI tests.
For framework changes, run the repository's all-feature test matrix in addition
to compiling the application. If you scaffolded a binary, a successful
cargo run that draws the expected first frame confirms the wiring.