Quickstart

Six copy-paste templates. Each picture is one frame from running that example. The guide is Getting started.

Minimal

cargo run --example template_minimal

A titled box. Press q to quit.

Minimal template: a bordered box that says hello
// Copyright (c) 2026 Moonbase 2090 LLC
// SPDX-License-Identifier: MIT

#![doc = include_str!("../README.md")]

use std::env;
use std::io::{self, Write};

use lunatui_core::{ClusterTable, Grid, Rect, Style};
use lunatui_runtime::{App, ControlFlow, RunOptions, is_quit, render_once, run};
use lunatui_term::Event;
use lunatui_theme::{Theme, Token, Variant};
use lunatui_widgets::{Block, Paragraph, RenderCx, Widget};

const WIDTH: u16 = 48;
const HEIGHT: u16 = 10;

struct Hello;

impl App for Hello {
    fn update(&mut self, event: &Event) -> ControlFlow {
        if is_quit(event) {
            ControlFlow::Break
        } else {
            ControlFlow::Continue
        }
    }

    fn render(&mut self, grid: &mut Grid, area: Rect, clusters: &mut ClusterTable) {
        let theme = Theme::builtin(Variant::Dark);
        let mut cx = RenderCx::new(grid, clusters, &theme);
        let text = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface0));
        let accent = Style::new()
            .fg(cx.token(Token::Accent))
            .bg(cx.token(Token::Surface0));
        let block = Block::bordered()
            .title(" hello ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        block.render(area, &mut cx);
        Paragraph::new("Hello. Press q to quit.")
            .style(text)
            .render(block.inner(area), &mut cx);
    }
}

fn main() -> io::Result<()> {
    let mut app = Hello;
    if env::args().any(|arg| arg == "--snapshot") {
        let (grid, clusters) = render_once(&mut app, WIDTH, HEIGHT);
        io::stdout().write_all(grid.display_text(&clusters).as_bytes())?;
        return Ok(());
    }
    run(app, RunOptions::new(WIDTH, HEIGHT))
}

List

cargo run --example template_list

Up and down move the selection.

List template: five widgets with Gauge selected
// Copyright (c) 2026 Moonbase 2090 LLC
// SPDX-License-Identifier: MIT

#![doc = include_str!("../README.md")]

use std::env;
use std::io::{self, Write};

use lunatui_core::{ClusterTable, Grid, Rect, Style};
use lunatui_runtime::{App, ControlFlow, RunOptions, is_quit, render_once, run};
use lunatui_term::{Event, KeyCode};
use lunatui_theme::{Theme, Token, Variant};
use lunatui_widgets::{Block, List, Paragraph, RenderCx, VecSource, Widget};

const WIDTH: u16 = 42;
const HEIGHT: u16 = 14;
const ITEMS: [&str; 5] = ["Calendar", "Gauge", "Table", "Sparkline", "Form"];

struct Screen {
    selected: usize,
}

impl Screen {
    fn new() -> Self {
        Self { selected: 1 }
    }

    fn move_selection(&mut self, later: bool) {
        if later {
            self.selected = self
                .selected
                .saturating_add(1)
                .min(ITEMS.len().saturating_sub(1));
        } else {
            self.selected = self.selected.saturating_sub(1);
        }
    }

    fn source() -> VecSource {
        let mut source = VecSource::new();
        for item in ITEMS {
            source.push_row(vec![String::from(item)]);
        }
        source
    }
}

impl App for Screen {
    fn update(&mut self, event: &Event) -> ControlFlow {
        if is_quit(event) {
            return ControlFlow::Break;
        }
        if let Event::Key(key) = event {
            match key.code {
                KeyCode::Up | KeyCode::Char('k') => self.move_selection(false),
                KeyCode::Down | KeyCode::Char('j') => self.move_selection(true),
                _ => {}
            }
        }
        ControlFlow::Continue
    }

    fn render(&mut self, grid: &mut Grid, area: Rect, clusters: &mut ClusterTable) {
        let theme = Theme::builtin(Variant::Dark);
        let mut cx = RenderCx::new(grid, clusters, &theme);
        let text = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface0));
        let accent = Style::new()
            .fg(cx.token(Token::Accent))
            .bg(cx.token(Token::Surface0));
        let raised = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface1));
        Block::new().style(text).render(area, &mut cx);
        let list_area = Rect::new(0, 0, area.width, area.height.saturating_sub(1));
        let block = Block::bordered()
            .title(" items ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        block.render(list_area, &mut cx);
        List::new(Self::source())
            .selected(Some(self.selected))
            .style(text)
            .highlight_style(raised)
            .highlight_symbol("> ", accent)
            .render(block.inner(list_area), &mut cx);
        let hint = Rect::new(
            1,
            area.height.saturating_sub(1),
            area.width.saturating_sub(2),
            1,
        );
        Paragraph::new("Up and down move the row. q quits.")
            .style(text)
            .render(hint, &mut cx);
    }
}

fn main() -> io::Result<()> {
    let mut app = Screen::new();
    if env::args().any(|arg| arg == "--snapshot") {
        let (grid, clusters) = render_once(&mut app, WIDTH, HEIGHT);
        io::stdout().write_all(grid.display_text(&clusters).as_bytes())?;
        return Ok(());
    }
    run(app, RunOptions::new(WIDTH, HEIGHT))
}

Form

cargo run --example template_form

Tab moves focus. Typed keys edit the focused field.

Form template: a name and a role, with the name filled in
// Copyright (c) 2026 Moonbase 2090 LLC
// SPDX-License-Identifier: MIT

#![doc = include_str!("../README.md")]

use std::env;
use std::io::{self, Write};

use lunatui_core::{ClusterTable, Grid, Rect, Style};
use lunatui_runtime::{App, ControlFlow, RunOptions, is_quit, render_once, run};
use lunatui_term::{Event, KeyCode};
use lunatui_theme::{Theme, Token, Variant};
use lunatui_widgets::{Block, Form, FormField, RenderCx, TextInput, Widget};

const WIDTH: u16 = 56;
const HEIGHT: u16 = 12;

struct Screen {
    name: String,
    role: String,
    focus: usize,
}

impl Screen {
    fn new() -> Self {
        Self {
            name: String::from("Ada"),
            role: String::new(),
            focus: 0,
        }
    }

    fn form(&self) -> Form {
        Form::new([
            FormField::new("Name").required(),
            FormField::new("Role").hint("shown on the card"),
        ])
        .focus(Some(self.focus))
    }

    fn field_mut(&mut self) -> &mut String {
        if self.focus == 0 {
            &mut self.name
        } else {
            &mut self.role
        }
    }
}

impl App for Screen {
    fn update(&mut self, event: &Event) -> ControlFlow {
        if is_quit(event) {
            return ControlFlow::Break;
        }
        if let Event::Key(key) = event {
            match key.code {
                KeyCode::Tab | KeyCode::Down => {
                    if let Some(next) = self.form().step(Some(self.focus), true) {
                        self.focus = next;
                    }
                }
                KeyCode::Up => {
                    if let Some(next) = self.form().step(Some(self.focus), false) {
                        self.focus = next;
                    }
                }
                KeyCode::Backspace => {
                    self.field_mut().pop();
                }
                KeyCode::Char(ch) if ch.is_ascii_graphic() => {
                    let field = self.field_mut();
                    if field.len() < 24 {
                        field.push(ch);
                    }
                }
                _ => {}
            }
        }
        ControlFlow::Continue
    }

    fn render(&mut self, grid: &mut Grid, area: Rect, clusters: &mut ClusterTable) {
        let theme = Theme::builtin(Variant::Dark);
        let mut cx = RenderCx::new(grid, clusters, &theme);
        let text = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface0));
        let accent = Style::new()
            .fg(cx.token(Token::Accent))
            .bg(cx.token(Token::Surface0));
        let block = Block::bordered()
            .title(" account ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        block.render(area, &mut cx);
        let inner = block.inner(area);
        let form = self.form();
        form.render(inner, &mut cx);
        for field in form.layout(inner) {
            let value = if field.index == 0 {
                self.name.as_str()
            } else {
                self.role.as_str()
            };
            if field.input.width == 0 || field.input.height == 0 {
                continue;
            }
            TextInput::new(value, value.len())
                .show_cursor(field.index == self.focus)
                .style(text)
                .field_style(text)
                .render(field.input, &mut cx);
        }
    }
}

fn main() -> io::Result<()> {
    let mut app = Screen::new();
    if env::args().any(|arg| arg == "--snapshot") {
        let (grid, clusters) = render_once(&mut app, WIDTH, HEIGHT);
        io::stdout().write_all(grid.display_text(&clusters).as_bytes())?;
        return Ok(());
    }
    run(app, RunOptions::new(WIDTH, HEIGHT))
}

Dashboard

cargo run --example template_dashboard

A sparkline, a gauge, and a table. The samples advance while it runs.

Dashboard template: a sparkline, a capacity gauge, and a service table
// Copyright (c) 2026 Moonbase 2090 LLC
// SPDX-License-Identifier: MIT

#![doc = include_str!("../README.md")]

use std::env;
use std::io::{self, Write};
use std::time::Instant;

use lunatui_core::{ClusterTable, Grid, Rect, Style};
use lunatui_layout::Constraint;
use lunatui_runtime::{App, ControlFlow, RunOptions, is_quit, render_once, run};
use lunatui_term::Event;
use lunatui_theme::{Theme, Token, Variant};
use lunatui_widgets::{Block, Gauge, RenderCx, Sparkline, Table, VecSource, Widget};

const WIDTH: u16 = 72;
const HEIGHT: u16 = 22;

struct Screen {
    started: Instant,
}

impl Screen {
    fn new() -> Self {
        Self {
            started: Instant::now(),
        }
    }

    fn tick(&self) -> u64 {
        u64::try_from(self.started.elapsed().as_millis() / 200).unwrap_or(0)
    }

    fn samples(&self) -> [u64; 48] {
        const TRAFFIC: [u64; 48] = [
            14, 15, 16, 18, 22, 27, 24, 19, 17, 16, 18, 21, 29, 36, 33, 26, 22, 19, 18, 17, 16, 18,
            20, 23, 21, 19, 17, 15, 16, 19, 25, 31, 28, 23, 20, 18, 17, 16, 18, 22, 26, 24, 21, 19,
            17, 16, 15, 14,
        ];
        let shift = usize::try_from(self.tick() % 48).unwrap_or(0);
        let mut samples = [0; 48];
        for (index, sample) in samples.iter_mut().enumerate() {
            let src = (index + shift) % TRAFFIC.len();
            *sample = TRAFFIC.get(src).copied().unwrap_or(0);
        }
        samples
    }

    fn ratio(&self) -> f32 {
        let step = u8::try_from(self.tick() % 8).unwrap_or(0);
        0.25 + f32::from(step) * 0.08
    }

    fn table() -> VecSource {
        let mut source = VecSource::new();
        for (service, state, latency) in [
            ("api", "ok", "12 ms"),
            ("worker", "warn", "40 ms"),
            ("edge", "ok", "9 ms"),
        ] {
            source.push_row(vec![
                String::from(service),
                String::from(state),
                String::from(latency),
            ]);
        }
        source
    }
}

impl App for Screen {
    fn update(&mut self, event: &Event) -> ControlFlow {
        if is_quit(event) {
            ControlFlow::Break
        } else {
            ControlFlow::Continue
        }
    }

    fn render(&mut self, grid: &mut Grid, area: Rect, clusters: &mut ClusterTable) {
        let theme = Theme::builtin(Variant::Dark);
        let mut cx = RenderCx::new(grid, clusters, &theme);
        let text = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface0));
        let accent = Style::new()
            .fg(cx.token(Token::Accent))
            .bg(cx.token(Token::Surface0));
        let warn = Style::new()
            .fg(cx.token(Token::Warn))
            .bg(cx.token(Token::Surface0));
        let raised = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface1));
        let rows = lunatui_layout::Layout::vertical([
            Constraint::Length(8),
            Constraint::Length(3),
            Constraint::Fill(1),
        ])
        .split(area);
        let Some(spark_area) = rows.segments.first().copied() else {
            return;
        };
        let Some(gauge_area) = rows.segments.get(1).copied() else {
            return;
        };
        let Some(table_area) = rows.segments.get(2).copied() else {
            return;
        };
        let spark = Block::bordered()
            .title(" requests ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        spark.render(spark_area, &mut cx);
        let samples = self.samples();
        Sparkline::new(&samples)
            .style(accent)
            .render(spark.inner(spark_area), &mut cx);
        let gauge = Block::bordered()
            .title(" capacity ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        gauge.render(gauge_area, &mut cx);
        Gauge::new(self.ratio())
            .gauge_style(accent)
            .track_style(text)
            .render(gauge.inner(gauge_area), &mut cx);
        let table = Block::bordered()
            .title(" services ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        table.render(table_area, &mut cx);
        let selected = usize::try_from(self.tick() % 3).unwrap_or(0);
        Table::new(
            Self::table(),
            vec![
                Constraint::Length(12),
                Constraint::Length(8),
                Constraint::Fill(1),
            ],
        )
        .header([("service", text), ("state", text), ("latency", text)])
        .selected(Some(selected))
        .style(text)
        .highlight_style(if selected == 1 { warn } else { raised })
        .render(table.inner(table_area), &mut cx);
    }

    fn needs_animation_frames(&self) -> bool {
        true
    }
}

fn main() -> io::Result<()> {
    let mut app = Screen::new();
    if env::args().any(|arg| arg == "--snapshot") {
        let (grid, clusters) = render_once(&mut app, WIDTH, HEIGHT);
        io::stdout().write_all(grid.display_text(&clusters).as_bytes())?;
        return Ok(());
    }
    run(app, RunOptions::new(WIDTH, HEIGHT))
}

Popover

cargo run --example template_popover

A panel beside an anchor. Enter toggles it.

Popover template: a filters panel open under a views anchor
// Copyright (c) 2026 Moonbase 2090 LLC
// SPDX-License-Identifier: MIT

#![doc = include_str!("../README.md")]

use std::env;
use std::io::{self, Write};

use lunatui_core::{ClusterTable, Grid, Rect, Style};
use lunatui_runtime::{App, ControlFlow, RunOptions, is_quit, render_once, run};
use lunatui_term::{Event, KeyCode};
use lunatui_theme::{Theme, Token, Variant};
use lunatui_widgets::{Block, Paragraph, Popover, RenderCx, Size, Widget};

const WIDTH: u16 = 56;
const HEIGHT: u16 = 16;

struct Screen {
    open: bool,
}

impl Screen {
    fn new() -> Self {
        Self { open: true }
    }
}

impl App for Screen {
    fn update(&mut self, event: &Event) -> ControlFlow {
        if is_quit(event) {
            return ControlFlow::Break;
        }
        if let Event::Key(key) = event {
            match key.code {
                KeyCode::Enter => self.open = !self.open,
                KeyCode::Esc => self.open = false,
                _ => {}
            }
        }
        ControlFlow::Continue
    }

    fn render(&mut self, grid: &mut Grid, area: Rect, clusters: &mut ClusterTable) {
        let theme = Theme::builtin(Variant::Dark);
        let mut cx = RenderCx::new(grid, clusters, &theme);
        let text = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface0));
        let accent = Style::new()
            .fg(cx.token(Token::Accent))
            .bg(cx.token(Token::Surface0));
        Block::new().style(text).render(area, &mut cx);
        let anchor = Rect::new(2, 1, 16, 3);
        let button = Block::bordered()
            .title(" views ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        button.render(anchor, &mut cx);
        if self.open {
            Popover::new(Paragraph::new("Open items\nAssigned to me\nClosed"), anchor)
                .title(" filters ")
                .size(Size::new(20, 5))
                .style(text)
                .border_style(accent)
                .title_style(accent)
                .render(area, &mut cx);
        }
        let hint = Rect::new(
            2,
            area.height.saturating_sub(2),
            area.width.saturating_sub(4),
            1,
        );
        Paragraph::new("Enter toggles the panel. q quits.")
            .style(text)
            .render(hint, &mut cx);
    }
}

fn main() -> io::Result<()> {
    let mut app = Screen::new();
    if env::args().any(|arg| arg == "--snapshot") {
        let (grid, clusters) = render_once(&mut app, WIDTH, HEIGHT);
        io::stdout().write_all(grid.display_text(&clusters).as_bytes())?;
        return Ok(());
    }
    run(app, RunOptions::new(WIDTH, HEIGHT))
}

Layout

cargo run --example template_layout

Header, sidebar, body, and a status bar.

Layout template: header, sidebar, body, and a status bar
// Copyright (c) 2026 Moonbase 2090 LLC
// SPDX-License-Identifier: MIT

#![doc = include_str!("../README.md")]

use std::env;
use std::io::{self, Write};

use lunatui_core::{ClusterTable, Grid, Rect, Style};
use lunatui_layout::{Constraint, Layout};
use lunatui_runtime::{App, ControlFlow, RunOptions, is_quit, render_once, run};
use lunatui_term::Event;
use lunatui_theme::{Theme, Token, Variant};
use lunatui_widgets::{Block, List, Paragraph, RenderCx, StatusBar, VecSource, Widget};

const WIDTH: u16 = 72;
const HEIGHT: u16 = 22;

struct Screen;

impl Screen {
    fn nav() -> VecSource {
        let mut source = VecSource::new();
        for item in ["Templates", "Gallery", "Docs"] {
            source.push_row(vec![String::from(item)]);
        }
        source
    }
}

impl App for Screen {
    fn update(&mut self, event: &Event) -> ControlFlow {
        if is_quit(event) {
            ControlFlow::Break
        } else {
            ControlFlow::Continue
        }
    }

    fn render(&mut self, grid: &mut Grid, area: Rect, clusters: &mut ClusterTable) {
        let theme = Theme::builtin(Variant::Dark);
        let mut cx = RenderCx::new(grid, clusters, &theme);
        let text = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface0));
        let accent = Style::new()
            .fg(cx.token(Token::Accent))
            .bg(cx.token(Token::Surface0));
        let raised = Style::new()
            .fg(cx.token(Token::TextPrimary))
            .bg(cx.token(Token::Surface1));
        let rows = Layout::vertical([
            Constraint::Length(3),
            Constraint::Fill(1),
            Constraint::Length(1),
        ])
        .split(area);
        let Some(header) = rows.segments.first().copied() else {
            return;
        };
        let Some(middle) = rows.segments.get(1).copied() else {
            return;
        };
        let Some(status) = rows.segments.get(2).copied() else {
            return;
        };
        let columns =
            Layout::horizontal([Constraint::Length(22), Constraint::Fill(1)]).split(middle);
        let Some(sidebar) = columns.segments.first().copied() else {
            return;
        };
        let Some(body) = columns.segments.get(1).copied() else {
            return;
        };
        let header_block = Block::bordered()
            .title(" header ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        header_block.render(header, &mut cx);
        Paragraph::new("Quickstart")
            .style(text)
            .render(header_block.inner(header), &mut cx);
        let side_block = Block::bordered()
            .title(" sidebar ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        side_block.render(sidebar, &mut cx);
        List::new(Self::nav())
            .selected(Some(0))
            .style(text)
            .highlight_style(raised)
            .render(side_block.inner(sidebar), &mut cx);
        let body_block = Block::bordered()
            .title(" body ")
            .style(text)
            .border_style(accent)
            .title_style(accent);
        body_block.render(body, &mut cx);
        Paragraph::new("The body fills the space beside the sidebar.")
            .style(text)
            .render(body_block.inner(body), &mut cx);
        StatusBar::new("  ready")
            .style(raised)
            .left([("templates", accent)])
            .render(status, &mut cx);
    }
}

fn main() -> io::Result<()> {
    let mut app = Screen;
    if env::args().any(|arg| arg == "--snapshot") {
        let (grid, clusters) = render_once(&mut app, WIDTH, HEIGHT);
        io::stdout().write_all(grid.display_text(&clusters).as_bytes())?;
        return Ok(());
    }
    run(app, RunOptions::new(WIDTH, HEIGHT))
}