pyguides

Building terminal UIs with Textual in Python

Textual is a Python framework for building rich terminal user interfaces. It brings web-like development patterns to the terminal: a widget tree, CSS-like styling, reactive state, and an event loop. Apps built with Textual run anywhere a terminal does, no graphical desktop required.

Why Textual?

Traditional terminal apps are limited to plain text output and line-buffered input. Textual treats the terminal as a canvas where you arrange widgets, apply CSS styling, and react to user input through events. The framework is built on top of Rich, so it inherits high-quality text rendering for free.

Key advantages:

  • Reactive updates. Widgets refresh automatically when their data changes.
  • CSS styling. Style widgets using a syntax close to web CSS.
  • Widget library. Pre-built components for buttons, inputs, lists, tables, trees, and progress bars.
  • Keyboard navigation. Focus management and shortcuts are wired in.

If you have built web UIs with React or Vue, the mental model will feel familiar. The terminal becomes a constrained but capable target.

Basic app structure

Every Textual application starts with the App class. It manages the event loop, the screen stack, and global input handling.

from textual.app import App

class MyApp(App):
    def compose(self):
        yield from []  # Add your widgets here

    def on_mount(self):
        self.title = "My First App"

if __name__ == "__main__":
    app = MyApp()
    app.run()

The compose() method is where you describe the widget hierarchy. Textual builds a tree with your App at the root and every child widget nested below it.

Screens and widgets

A Screen represents a full terminal view, while a Widget is an individual component. The split lets you organize larger apps into distinct views you can push and pop.

from textual.app import App, ComposeResult
from textual.widgets import Static, Button

class WelcomeScreen(Static):
    def compose(self) -> ComposeResult:
        yield Button("Start", id="start")
        yield Button("Quit", id="quit")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "start":
            self.app.push_screen("main")
        elif event.button.id == "quit":
            self.app.exit()

class MyApp(App):
    def compose(self) -> ComposeResult:
        yield WelcomeScreen()

Textual’s message system handles communication between widgets. When a button is pressed, it emits a Button.Pressed message that you can intercept in on_button_pressed(). The same pattern applies to inputs, lists, and any custom widget you define.

Working with layouts

Textual provides several layout containers for arranging content:

  • Horizontal: places children side by side.
  • Vertical: stacks children top to bottom.
  • Grid: arranges children in a grid.
  • Dock: docks widgets to edges of the parent.
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual.containers import Horizontal

class LayoutExample(App):
    CSS = """
    Screen {
        layout: vertical;
    }
    .row {
        layout: horizontal;
        height: 3;
    }
    .box {
        width: 1fr;
        height: 100%;
        border: solid green;
        content-align: center middle;
    }
    """

    def compose(self) -> ComposeResult:
        with Horizontal(classes="row"):
            yield Static("Left", classes="box")
            yield Static("Center", classes="box")
            yield Static("Right", classes="box")
        with Horizontal(classes="row"):
            yield Static("A", classes="box")
            yield Static("B", classes="box")

if __name__ == "__main__":
    app = LayoutExample()
    app.run()

The 1fr unit means “one fraction” of available space, letting you create flexible proportions. Run the script and you will see two horizontal rows of bordered boxes, each row sharing the terminal width.

CSS styling for terminals

Textual supports a subset of CSS specifically designed for terminal rendering. You define styles in the CSS class attribute or in an external .tcss stylesheet.

from textual.app import App, ComposeResult
from textual.widgets import Button, Input

class StyledApp(App):
    CSS = """
    Screen {
        background: $surface;
        align: center middle;
    }
    Button {
        margin: 1;
        min-width: 16;
    }
    #submit {
        background: $primary;
        color: $text;
    }
    #submit:hover {
        background: $accent;
    }
    Input {
        margin-bottom: 1;
    }
    """

    def compose(self) -> ComposeResult:
        yield Input(placeholder="Enter your name", id="name")
        yield Button("Submit", id="submit", variant="primary")

Terminal colors use dollar-sign variables like $surface, $primary, and $accent. These adapt to light or dark terminal themes automatically, so a single stylesheet works across user preferences.

Practical example: a task tracker

Here is a small task tracker that combines widgets, events, and state.

from textual.app import App, ComposeResult
from textual.widgets import Input, Button, ListItem, ListView, Static
from textual.containers import Horizontal

class TrackerApp(App):
    CSS = """
    Screen {
        layout: vertical;
        margin: 1;
    }
    #title {
        text-style: bold;
        color: $accent;
    }
    #input-zone {
        layout: horizontal;
        height: 3;
    }
    Input {
        width: 1fr;
        margin-right: 1;
    }
    ListView {
        border: solid $primary;
        height: 100%;
    }
    """

    def compose(self) -> ComposeResult:
        yield Static("My tasks", id="title")
        with Horizontal(id="input-zone"):
            yield Input(placeholder="Add a task...", id="task-input")
            yield Button("Add", id="add-btn", variant="success")
        yield ListView(id="task-list")

The handlers below append items when the user clicks the button or presses Enter inside the input.

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "add-btn":
            self._add_task()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        self._add_task()

    def _add_task(self) -> None:
        field = self.query_one("#task-input", Input)
        text = field.value.strip()
        if text:
            self.query_one("#task-list", ListView).append(
                ListItem(Static(text))
            )
            field.value = ""

if __name__ == "__main__":
    TrackerApp().run()

The example uses three Textual ideas at once: input handling, list mutation, and widget lookup with the #id selector syntax. The query_one() call is how widgets find each other across the tree.

What’s next

Textual supports keyboard shortcuts, animations, modal dialogs, async workers, and reactive data binding. The framework also ships a developer console that shows live logs and CSS reloads.

For your next project, try a file browser, a log viewer with live tail, or a dashboard for system metrics. The patterns you saw here — compose(), message handlers, and CSS — carry over directly. For broader context on terminal Python programs, see the CLI argparse basics guide and the working with files guide.

A few practical tips when you grow past the toy example: keep CSS rules in an external file once they exceed a screen of text, use reactive attributes for any state that more than one widget needs to read, and lean on App.action_* methods to wire keyboard shortcuts so users can drive the interface without the mouse. Textual ships a developer console that hot-reloads CSS and prints print() output from your app, which shortens the feedback loop considerably while you iterate on layout. Textual gives you a friendly path from script to interactive terminal application without leaving Python, and the framework rewards small, frequent runs over big-bang refactors.