Building Terminal UIs with Textual
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.