Building Terminal UIs with Textual

· 5 min read · Updated March 17, 2026 · intermediate
python tui textual terminal gui

Textual is a Python framework for building rich terminal user interfaces (TUIs). It brings web-like development patterns to the terminal, letting you create interactive applications that run entirely in the command line.

Why Textual?

Traditional terminal apps are limited to basic text output and simple input handling. Textual changes this by treating the terminal as a canvas where you can arrange widgets, apply CSS styling, and handle user interactions with event-driven architecture.

Key advantages include:

  • Reactive updates: Widgets automatically refresh when data changes
  • CSS styling: Style your TUI using familiar CSS syntax
  • Widget library: Pre-built components like buttons, inputs, lists, and tables
  • Keyboard navigation: Built-in focus management and shortcuts

If you’ve built web UIs with frameworks like React, Textual’s reactive patterns will feel familiar.

Basic App Structure

Every Textual application starts with the App class. This is the foundation that manages screens, handles input, and runs the event loop.

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 define the widget hierarchy. Textual builds a tree of widgets, with your App at the root.

Screens and Widgets

A Screen represents a full terminal view, while Widget are individual components. This separation lets you organize your app into distinct views.

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()

output

The app displays two buttons: [Start] and [Quit]

Clicking Start transitions to the main screen

Clicking Quit closes the application


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()`.

## Working with Layouts

Textual provides several layout widgets for arranging content:

- `Horizontal`: Places children side by side
- `Vertical`: Stacks children top to bottom
- `Grid`: Arranges children in a grid pattern
- `Dock`: Docks widgets to edges

```python
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()

output

+------+-------+------+

| Left | Center| Right|

+------+-------+------+

| A | B | |

+------+-------+------+

Two rows of boxes arranged with horizontal layout


The `1fr` unit means "one fraction" of available space, letting you create flexible proportions.

## CSS Styling for Terminals

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

```python
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")

output

Dark background with centered input field

Submit button in primary color (typically blue)

Hover state changes button to accent color


Terminal colors use dollar-sign variables like `$surface`, `$primary`, `$accent`. These adapt to light or dark terminal themes automatically.

## Practical Example: Todo List

Here's a working todo app demonstrating widgets, events, and state management:

```python
from textual.app import App, ComposeResult
from textual.widgets import Input, Button, ListItem, List, Static
from textual.containers import Horizontal

class TodoApp(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;
    }
    List {
        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 List(id="todo-list")

    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:
        input_widget = self.query_one("#task-input", Input)
        task_text = input_widget.value.strip()
        if task_text:
            list_widget = self.query_one("#todo-list", List)
            list_widget.append(ListItem(Static(task_text)))
            input_widget.value = ""

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

output

+----------------------------------+

| My Tasks |

+----------------------------------+

| [Input field…] [Add] |

+----------------------------------+

| |

| (empty list with border) |

+----------------------------------+

After adding tasks:

+----------------------------------+

| • Buy groceries |

| • Finish report |

| • Call mom |

+----------------------------------+


This example shows input handling, list management, and widget queries with the `#id` selector syntax.

## What's Next

Textual supports advanced features like keyboard shortcuts, animations, modal dialogs, and data binding. The framework has comprehensive documentation at textual.textualize.io.

For your next project, try building a file browser, a log viewer, or a dashboard with live data updates. The skills you learned here transfer directly to more complex applications.