Ever get tired of spinning up a whole web framework just to display some real-time data? You know the drill: set up Flask or Django, wrestle with some JavaScript, fight with CSS, and before you know it, your simple monitoring tool has become a week-long project.
I’ve been there. Sometimes, you just want a clean, fast, keyboard-friendly dashboard for your data without all the browser baggage.
What if I told you that you could build something that feels just as dynamic and responsive as a modern web app, but entirely in Python and running right in your terminal? It sounds a little wild, but it's totally possible. We're going to do it together using a fantastic library called Textual.
Think of Textual as a way to build sophisticated user interfaces (UIs) for your command line. We're talking layouts, buttons, tables, and reactive components that update automatically. By the end of this, you’ll have a fully functional data dashboard, and you might just start rethinking what your terminal is capable of.
So, What’s the Plan?
We’re going to build this thing piece by piece. First, we’ll set up the basic building blocks, then sketch out the layout, pump in some data, and finally, wire up all the interactive buttons and controls. Let’s get started.
First things first, you’ll need to install Textual. It’s a simple pip install:
pip install textual
Now, let's start coding.
Step 1: Creating Our First "Smart" Widget
Before we build the whole dashboard, let's create a small, reusable component. We need some cards at the top to display key stats like "Total Rows" and "Total Sales."
The magic of Textual is its concept of "reactivity." Imagine you have a Lego block that automatically changes color whenever you tell it a new number. That’s kind of what we’re building here. We'll create a StatsCard that automatically updates its display whenever its value changes.
Here’s the code for that:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Header, Footer, Button, DataTable, Static,
Input, Label, ProgressBar, Tree, Select
)
from textual.reactive import reactive
from textual import on
from datetime import datetime
import random
class StatsCard(Static):
value = reactive(0) # This is the magic!
def __init__(self, title: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title = title
def compose(self) -> ComposeResult:
yield Label(self.title)
yield Label(str(self.value), id="stat-value")
def watch_value(self, new_value: int) -> None:
# This function runs automatically when `self.value` changes
if self.is_mounted:
self.query_one("#stat-value", Label).update(str(new_value))
See that value = reactive(0) line? That’s the core idea. By declaring value as reactive, we’re telling Textual to keep an eye on it. The watch_value method is a special function that Textual automatically calls whenever self.value is updated from anywhere else in our code.
No manual refreshing, no complicated state management. We just change the value, and the UI takes care of itself. Pretty neat, right?
Step 2: Designing the Dashboard's Blueprint
Now that we have our smart little StatsCard, let's lay out the entire dashboard. This is where Textual really shines for anyone who's dabbled in web development. It uses a CSS-like syntax to style and position everything.
We'll create our main app class, DataDashboard, and define the entire look and feel right at the top.
class DataDashboard(App):
CSS = """
Screen {
background: $surface;
}
#main-container {
height: 100%;
padding: 1;
}
#stats-row {
height: auto;
margin-bottom: 1;
}
StatsCard {
border: solid $primary;
height: 5;
padding: 1;
margin-right: 1;
width: 1fr;
}
#stat-value {
text-style: bold;
color: $accent;
content-align: center middle;
}
#control-panel {
height: 12;
border: solid $secondary;
padding: 1;
margin-bottom: 1;
}
/* ... more styles for other components ... */
"""
BINDINGS = [
("d", "toggle_dark", "Toggle Dark Mode"),
("q", "quit", "Quit"),
("a", "add_row", "Add Row"),
]
total_rows = reactive(0)
total_sales = reactive(0)
avg_rating = reactive(0.0)
We’re doing a few key things here:
- CSS: We're defining styles for our components using simple selectors like
#main-containerandStatsCard. The1frunit for width will feel very familiar if you've used CSS Grid or Flexbox—it just means "take up one fraction of the available space." - BINDINGS: This is awesome. We're setting up keyboard shortcuts for our app. Press 'd' to toggle dark mode, 'q' to quit. It makes the app feel incredibly professional and fast to use.
- Reactive State: Just like in our
StatsCard, we're defining the core data points for our entire app (total_rows,total_sales) as reactive variables.
Next, we need to actually place our components on the screen. We do this in the compose method, which acts as the blueprint for our UI.
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container(id="main-container"):
with Horizontal(id="stats-row"):
yield StatsCard("Total Rows", id="card-rows")
yield StatsCard("Total Sales", id="card-sales")
yield StatsCard("Avg Rating", id="card-rating")
with Vertical(id="control-panel"):
yield Input(placeholder="Product Name")
yield Select([("Electronics", "electronics"), ("Books", "books")], prompt="Select Category")
with Horizontal():
yield Button("Add Row", variant="primary", id="btn-add")
yield Button("Clear Table", variant="warning", id="btn-clear")
yield ProgressBar(total=100, id="progress")
with Horizontal(id="data-section"):
with Container(id="left-panel"):
# ... Tree navigation widget ...
yield Tree("Dashboard")
yield DataTable(id="data-table")
yield Footer()
This code might look a bit dense, but it's incredibly straightforward. We're using with statements and containers like Horizontal and Vertical to visually stack our components. We add our three StatsCard widgets, a control panel with inputs and buttons, and a main data section with a navigation tree and a big data table.
It’s a clean, declarative way to build a UI. You can literally read the code and picture how the dashboard will look.
Step 3: Breathing Life into the Dashboard
Our dashboard looks great, but it’s empty. Let’s get some data in there and make things move.
We'll use a special method called on_mount, which Textual runs automatically after the UI has been drawn for the first time. It's the perfect place to load initial data.
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("ID", "Product", "Category", "Price", "Sales", "Rating")
self.generate_sample_data(5)
self.set_interval(0.1, self.update_progress) # Animate the progress bar
def generate_sample_data(self, count: int = 5) -> None:
table = self.query_one(DataTable)
# ... logic to create random product data ...
for _ in range(count):
# ... generate a random row ...
table.add_row(id, product, category, price, sales, rating)
self.total_rows += 1
self.total_sales += sales
self.update_stats()
def update_stats(self) -> None:
self.query_one("#card-rows", StatsCard).value = self.total_rows
self.query_one("#card-sales", StatsCard).value = self.total_sales
# ... calculate and update average rating ...
def update_progress(self) -> None:
progress = self.query_one(ProgressBar)
progress.advance(1)
if progress.progress >= 100:
progress.progress = 0
This is where the magic we set up earlier pays off.
In generate_sample_data, after adding rows to our table, we simply update self.total_rows and self.total_sales. Then we call update_stats, which assigns these values to the .value property of our StatsCard widgets.
Because we made those properties reactive, the cards instantly update on the screen. No extra work needed. We also set up a little interval to make our progress bar animate, giving the dashboard a lively feel.
Step 4: Making It Interactive
A dashboard isn't much fun if you can't click things. Let's wire up our buttons. Textual makes this incredibly easy with decorators.
@on(Button.Pressed, "#btn-add")
def handle_add_button(self) -> None:
# Get values from the input fields
name_input = self.query_one("#input-name", Input)
# ...
if name_input.value:
# Add a new row to the table with the input data
# ...
self.total_rows += 1
self.total_sales += sales
self.update_stats()
name_input.value = "" # Clear the input
@on(Button.Pressed, "#btn-clear")
def handle_clear_button(self) -> None:
table = self.query_one(DataTable)
table.clear()
self.total_rows = 0
self.total_sales = 0
self.update_stats()
The @on(Button.Pressed, "#btn-add") syntax is so clear. It just means: "Hey Textual, whenever the button with the ID #btn-add is pressed, run this function."
Inside these functions, we do our logic—adding a new row or clearing the table—and then we just call update_stats(). Again, the reactive system handles updating the UI for us automatically. It’s a beautifully simple and powerful pattern.
Finally, to make it all run, we add this little snippet at the end:
if __name__ == "__main__":
app = DataDashboard()
app.run()
So, What Did We Just Build?
Let's take a step back. In a relatively small amount of Python code, we just built a full-blown interactive application that runs in the terminal. It has:
- A responsive layout that adjusts to your terminal size.
- Reactive stat cards that update in real-time.
- A control panel with text inputs, dropdowns, and clickable buttons.
- A sortable data table.
- A slick dark mode toggle and other keyboard shortcuts.
All of this, without touching a single line of HTML, JavaScript, or traditional CSS.
This is more than just a novelty. It's a genuinely powerful way to build internal tools, CLI applications, or quick monitoring dashboards. The next time you need to visualize some data and don't want the overhead of a web server, remember that you have a surprisingly capable UI toolkit waiting for you right in your terminal. Give Textual a try—I think you'll be impressed.




