Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
blacktop avatar

Ratatui Tui

  • 247 installs
  • 17 repo stars
  • Updated July 16, 2026
  • blacktop/dotfiles

Ratatui-tui is a skill for building terminal UIs in Rust with the ratatui crate following 2026 best practices.

About

Ratatui-tui helps build terminal user interfaces with the ratatui Rust crate. A developer uses it when creating new TUI apps, adding widgets and layouts, wiring keyboard navigation and state management, or integrating images and loading animations. It covers the v0.30.1 API, the Elm Architecture, StatefulWidget, async event handling, and release optimization, with copyable templates to start from.

  • Builds terminal UIs with ratatui following 2026 Rust best practices
  • Covers v0.30.1 API, Elm Architecture, StatefulWidget, and async event handling
  • Includes copyable templates and ratatui-image / tui-shimmer integration

Ratatui Tui by the numbers

  • 247 all-time installs (skills.sh)
  • Ranked #49 of 121 Rust skills by installs in the Skillselion catalog
  • Data as of Jul 28, 2026 (Skillselion catalog sync)
At a glance

ratatui-tui capabilities & compatibility

Capabilities
ratatui tui · tui development
Use cases
frontend · ui design
From the docs

What ratatui-tui says it does

Build terminal UIs with ratatui following 2026 Rust best practices.
SKILL.md
Covers v0.30.1 API, Elm Architecture, StatefulWidget, color-eyre.
SKILL.md
Current stable: **0.30.1** (2026-06-05, MSRV 1.88, edition 2024).
SKILL.md
npx skills add https://github.com/blacktop/dotfiles --skill ratatui-tui

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs247
repo stars17
Last updatedJuly 16, 2026
Repositoryblacktop/dotfiles

What it does

Build a Rust terminal UI with ratatui, wiring widgets, layouts, keyboard navigation, and async events.

Who is it for?

Creating new TUI apps, adding widgets/layouts, and keyboard navigation or state management in ratatui.

When should I use this skill?

Creating a new TUI app, adding widgets or layouts, or reviewing TUI code.

What you get

A working Rust terminal UI is built with widgets, layouts, keyboard navigation, and async events.

By the numbers

  • Targets ratatui v0.30.1 (2026-06-05)

Files

SKILL.mdMarkdownGitHub ↗

Ratatui TUI Development

Quick Start

1. Copy template to project:

   cp -r ~/.agents/skills/ratatui-tui/assets/templates/<template>/* .

Or generate from the official templates repo:

   cargo install --locked cargo-generate
   cargo generate ratatui/templates

2. Run:

   cargo run

Version Notes (0.30.x)

Current stable: 0.30.1 (2026-06-05, MSRV 1.88, edition 2024).

  • Modular workspace: apps keep depending on ratatui; widget libraries

should depend on ratatui-core for API stability and fewer dependencies.

  • `ratatui::run(|terminal| ...)`: initializes the terminal, installs a

panic hook that restores it, runs the closure, and restores on exit.

  • `Block::shadow(...)` (new in 0.30.1): drop shadows for blocks/popups.
  • Breaking since 0.29: block::Title removed, layout::Alignment renamed

to HorizontalAlignment, Flex::SpaceAround now matches flexbox semantics (use Flex::SpaceEvenly for the old behavior), Marker is non-exhaustive.

  • Performance: disabling default-features also disables layout-cache;

re-enable it explicitly or layout performance drops sharply.

Template Selection

ComplexityTemplateUse Case
Minimalhello-worldLearning, quick demos
Simplesimple-appSingle-screen apps, tools
Asyncasync-appBackground tasks, network
Fullcomponent-appMulti-view, config, logging

Decision tree:

  • Need async/network? → async-app
  • Multiple screens/components? → component-app
  • Just a simple tool? → simple-app
  • Learning ratatui? → hello-world

Project Setup

Minimal Cargo.toml

[package]
name = "my-tui"
version = "0.1.0"
edition = "2024"

[dependencies]
ratatui = "0.30"
crossterm = "0.29"
color-eyre = "0.6"

Full Dependencies (component-app)

[dependencies]
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
color-eyre = "0.6"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
config = "0.15"
dirs = "6"

# Optional: image support
ratatui-image = { version = "5", features = ["chafa-static"] }

# Optional: shimmer text animation
tui-shimmer = "0.1"

Release Profile

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true

Core Loop: TEA (The Elm Architecture)

Model → Message → Update → View
  ↑                         |
  └─────────────────────────┘
struct App {
    counter: i32,
    should_quit: bool,
}

enum Message {
    Increment,
    Decrement,
    Quit,
}

impl App {
    fn update(&mut self, msg: Message) {
        match msg {
            Message::Increment => self.counter += 1,
            Message::Decrement => self.counter -= 1,
            Message::Quit => self.should_quit = true,
        }
    }

    fn view(&self, frame: &mut Frame) {
        let text = format!("Counter: {}", self.counter);
        frame.render_widget(Paragraph::new(text), frame.area());
    }
}

Styling Rules

Use Stylize trait helpers:

use ratatui::style::Stylize;

// Good
"text".bold()
"text".dim()
"text".cyan()
"text".on_dark_gray()
"text".bold().cyan()

// Avoid
Style::default().fg(Color::White)  // hardcoded white
Style::default().fg(Color::Black)  // hardcoded black
Style::new().add_modifier(Modifier::BOLD)  // verbose

Color palette:

  • Primary: .cyan(), .green()
  • Error: .red()
  • Warning: .yellow() (sparingly)
  • Muted: .dim(), .dark_gray()
  • Accent: .magenta()

Text wrapping:

use textwrap::wrap;
use ratatui::text::Line;

let wrapped: Vec<Line> = wrap(&long_text, width as usize)
    .into_iter()
    .map(|cow| Line::from(cow.into_owned()))
    .collect();

See: references/style-guide.md

Widget Patterns

StatefulWidget

struct MyList {
    items: Vec<String>,
}

struct MyListState {
    selected: usize,
}

impl StatefulWidget for MyList {
    type State = MyListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        // render with state.selected
    }
}

// Usage
frame.render_stateful_widget(my_list, area, &mut state);

Layout

let [header, main, footer] = Layout::vertical([
    Constraint::Length(1),
    Constraint::Fill(1),
    Constraint::Length(1),
]).areas(frame.area());

let [left, right] = Layout::horizontal([
    Constraint::Percentage(30),
    Constraint::Fill(1),
]).areas(main);

Built-in State Types

  • ListState - for List widget
  • TableState - for Table widget
  • ScrollbarState - for Scrollbar

See: references/architecture-patterns.md

Async Event Handling

use crossterm::event::{EventStream, Event, KeyCode};
use futures::StreamExt;
use tokio::select;

async fn run(mut app: App) -> Result<()> {
    let mut events = EventStream::new();

    loop {
        // Render
        terminal.draw(|f| app.view(f))?;

        // Handle events
        select! {
            Some(Ok(event)) = events.next() => {
                if let Event::Key(key) = event {
                    match key.code {
                        KeyCode::Char('q') => break,
                        KeyCode::Up => app.update(Message::Up),
                        KeyCode::Down => app.update(Message::Down),
                        _ => {}
                    }
                }
            }
            // Add other channels here (background tasks, timers)
        }

        if app.should_quit {
            break;
        }
    }
    Ok(())
}

See: references/async-patterns.md

Image Integration

use ratatui_image::{picker::Picker, StatefulImage, Resize};
use std::thread;

// Query terminal protocol support once at startup; keep it on the app
let picker = Picker::from_query_stdio()?;

// Load and resize in a background thread (`area` is the target Rect
// from your layout; clone the picker so the original stays reusable)
let (tx, rx) = std::sync::mpsc::channel();
let mut worker = picker.clone();
thread::spawn(move || {
    let dyn_img = image::open("photo.png").unwrap();
    let protocol = worker.new_protocol(dyn_img, area.into(), Resize::Fit(None));
    tx.send(protocol).unwrap();
});

// In render, use StatefulImage for efficient redraw
if let Ok(protocol) = rx.try_recv() {
    image_state = Some(protocol);
}
if let Some(ref mut img) = image_state {
    frame.render_stateful_widget(StatefulImage::default(), area, img);
}

Key points:

  • Use chafa-static feature for portable binaries
  • Query protocol once, not per-frame
  • Offload resize/encode to background thread
  • Use StatefulImage to avoid re-encoding on redraws

See: references/image-integration.md

Shimmer / Loading Animation

tui-shimmer sweeps a highlight across text — the "Loading…"/"Thinking…" effect used by coding-agent TUIs.

use ratatui::style::Style;
use ratatui::text::Line;
use tui_shimmer::{shimmer_spans_with_style, shimmer_spans_with_style_at_phase};

// Time-driven (call every frame; re-render on a tick to animate)
let spans = shimmer_spans_with_style("Loading...", Style::new().cyan());
frame.render_widget(Line::from(spans), area);

// Deterministic: drive phase (0.0..1.0) from app state — testable, pausable
let phase = (self.start.elapsed().as_secs_f32() / 2.0) % 1.0;
let spans = shimmer_spans_with_style_at_phase("Working...", Style::new().cyan(), phase);

Key points:

  • Animation needs redraws: add a tick event (~80-120ms) to the event loop

(select! with tokio::time::interval, or event::poll timeout)

  • Prefer the _at_phase variant with phase stored in the Model — keeps

rendering pure and animation testable

  • True color with automatic fallback for limited terminals
  • API is experimental until 1.0 — pin and review minor bumps

Error Handling

ratatui::run() / ratatui::init() install a panic hook that restores the terminal before panicking — do not write one by hand. Install color-eyre first so the terminal is restored before its report prints:

use color_eyre::eyre::Result;

fn main() -> Result<()> {
    color_eyre::install()?;        // eyre hooks before terminal init
    // App::run is the app's own main loop (see templates), not a ratatui API
    let result = ratatui::run(|terminal| App::default().run(terminal));
    Ok(result?)
}

Only write a manual panic hook when constructing Terminal/Backend by hand instead of via ratatui::init().

Error propagation:

// Use ? for recoverable errors
let file = std::fs::read_to_string(path)?;

// Use color_eyre context
let config = load_config()
    .wrap_err("Failed to load configuration")?;

Release Build

cargo build --release

Binary at target/release/<name>.

Size optimization — replaces the Release Profile block above when binary size matters more than speed:

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z"  # size over speed

Templates Overview

hello-world (~25 lines)

Minimal ratatui demo using ratatui::run().

simple-app (~80 lines)

Synchronous event loop, App struct, basic render.

async-app (~120 lines)

Tokio runtime, EventStream, select! pattern.

component-app (~300 lines)

Full modular structure:

  • main.rs - entry point
  • app.rs - App state, update logic
  • event.rs - event handling
  • ui.rs - rendering
  • action.rs - Action enum
  • tui.rs - terminal setup
  • config.rs - configuration with dirs
  • logging.rs - tracing setup

Common Patterns

Centered Popup

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let [_, center, _] = Layout::vertical([
        Constraint::Percentage((100 - percent_y) / 2),
        Constraint::Percentage(percent_y),
        Constraint::Percentage((100 - percent_y) / 2),
    ]).areas(area);

    let [_, center, _] = Layout::horizontal([
        Constraint::Percentage((100 - percent_x) / 2),
        Constraint::Percentage(percent_x),
        Constraint::Percentage((100 - percent_x) / 2),
    ]).areas(center);

    center
}

With a drop shadow (0.30.1+):

use ratatui::layout::Offset;
use ratatui::widgets::{Block, Shadow};

let popup = Block::bordered()
    .title("Confirm")
    .shadow(Shadow::dark_shade().offset(Offset::new(2, 1)));

Key Bindings Display

let help = Line::from(vec![
    " q ".bold().cyan(),
    "quit ".dim(),
    " ↑↓ ".bold().cyan(),
    "navigate ".dim(),
    " Enter ".bold().cyan(),
    "select ".dim(),
]);

Status Bar

let status = Line::from(vec![
    " MODE ".bold().on_cyan(),
    format!(" {} items ", count).dim().into(),
]);

Multi-Agent TUI Review Workflow

workflows/tui-review.js is a dynamic-workflow template for Claude Code's Workflow tool. It fans out one reviewer per TUI dimension — TEA architecture, terminal safety, styling, event handling, render performance — then adversarially verifies each finding before reporting, so only confirmed issues survive. In agents without the Workflow tool (Codex, Gemini), skip the script and apply those five dimensions as a manual review checklist instead.

Treat it as a template, not a script to run verbatim: adjust the target path, dimensions, and severity threshold to the codebase. Run it after substantial TUI changes or before a release:

Workflow({
  scriptPath: "~/.agents/skills/ratatui-tui/workflows/tui-review.js",
  args: { path: "src/" },
})

Or ask: "run the TUI review workflow from the ratatui-tui skill on src/".

Checklist

Before shipping:

  • [ ] cargo fmt
  • [ ] cargo clippy --all-features clean
  • [ ] No unwrap() outside tests
  • [ ] Terminal restored on all exit paths (ratatui::run() or init/restore)
  • [ ] cargo build --release succeeds
  • [ ] Test on target terminal(s)

Related skills

Rustfrontend

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.