How to Design a Fully Interactive, Reactive, and Dynamic Terminal-Based Data Dashboard Using Textual?

Creating a Sophisticated Interactive Dashboard with Textual

This guide walks you through building a feature-rich, interactive dashboard using the Textual framework, showcasing how terminal-based UI libraries can rival the expressiveness and responsiveness of contemporary web dashboards. By incrementally developing the interface-adding widgets, layouts, reactive states, and event handlers-you’ll observe how Textual operates as a live UI engine, even within environments like Google Colab. By the end, you’ll appreciate how seamlessly tables, trees, forms, and progress bars integrate into a unified, fast, and elegant application.

Setting Up the Environment and Crafting Reactive Components

First, we prepare our workspace by installing necessary packages and importing essential modules to build our Textual app. A key reusable component, StatsCard, is designed to automatically update its display when its underlying value changes, demonstrating Textual’s powerful reactive system that simplifies dynamic UI creation.

!pip install textual textual-web nest-asyncio

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)

    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 watchvalue(self, newvalue: int) -> None:
        if self.ismounted:
            try:
                self.queryone("#stat-value", Label).update(str(newvalue))
            except Exception:
                pass

Defining the Dashboard Structure and Styling

Next, we create the DataDashboard class, where global styles, keyboard shortcuts, and reactive properties are configured. This approach gives us full control over the dashboard’s appearance and behavior without needing HTML or JavaScript, enabling a clean and maintainable design.

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; }
    #data-section { height: 1fr; }
    #left-panel { width: 30; border: solid $secondary; padding: 1; margin-right: 1; }
    DataTable { height: 100%; border: solid $primary; }
    Input { margin: 1 0; }
    Button { margin: 1 1 1 0; }
    ProgressBar { margin: 1 0; }
    """

    BINDINGS = [
        ("d", "toggledark", "Toggle Dark Mode"),
        ("q", "quit", "Quit"),
        ("a", "addrow", "Add Row"),
        ("c", "cleartable", "Clear Table"),
    ]

    totalrows = reactive(0)
    totalsales = reactive(0)
    avgrating = reactive(0.0)

Constructing the User Interface Layout

We then assemble the dashboard’s visual components, organizing containers, statistic cards, input fields, buttons, a navigation tree, and a data table. This declarative layout design allows us to visualize the dashboard’s structure clearly and intuitively.

    def compose(self) -> ComposeResult:
        yield Header(showclock=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", id="input-name")
                yield Select(
                    [("Electronics", "electronics"),
                     ("Books", "books"),
                     ("Clothing", "clothing")],
                    prompt="Select Category",
                    id="select-category"
                )
                with Horizontal():
                    yield Button("Add Row", variant="primary", id="btn-add")
                    yield Button("Clear Table", variant="warning", id="btn-clear")
                    yield Button("Generate Data", variant="success", id="btn-generate")
                yield ProgressBar(total=100, id="progress")

            with Horizontal(id="data-section"):
                with Container(id="left-panel"):
                    yield Label("Navigation")
                    tree = Tree("Dashboard")
                    tree.root.expand()
                    products = tree.root.add("Products", expand=True)
                    products.addleaf("Electronics")
                    products.addleaf("Books")
                    products.addleaf("Clothing")
                    tree.root.addleaf("Reports")
                    tree.root.addleaf("Settings")
                    yield tree

                yield DataTable(id="data-table")

        yield Footer()

Implementing Data Handling and Dynamic Updates

We add the core logic to populate the data table with sample entries, calculate summary statistics, and animate the progress bar. Textual’s reactive framework allows backend data changes to instantly reflect in the UI, creating a lively and responsive experience.

    def onmount(self) -> None:
        table = self.queryone(DataTable)
        table.addcolumns("ID", "Product", "Category", "Price", "Sales", "Rating")
        table.cursortype = "row"
        self.generatesampledata(5)
        self.setinterval(0.1, self.updateprogress)

    def generatesampledata(self, count: int = 5) -> None:
        table = self.queryone(DataTable)
        categories = ["Electronics", "Books", "Clothing"]
        products = {
            "Electronics": ["Laptop", "Smartwatch", "Camera", "Speaker"],
            "Books": ["Biography", "Science Fiction", "Cookbook", "Travel Guide"],
            "Clothing": ["T-Shirt", "Jeans", "Jacket", "Sneakers"]
        }

        for  in range(count):
            category = random.choice(categories)
            product = random.choice(products[category])
            rowid = self.totalrows + 1
            price = round(random.uniform(15, 600), 2)
            sales = random.randint(5, 150)
            rating = round(random.uniform(1, 5), 1)

            table.addrow(
                str(rowid),
                product,
                category,
                f"${price}",
                str(sales),
                str(rating)
            )

            self.totalrows += 1
            self.totalsales += sales

        self.updatestats()

    def updatestats(self) -> None:
        self.queryone("#card-rows", StatsCard).value = self.totalrows
        self.queryone("#card-sales", StatsCard).value = self.totalsales

        if self.totalrows > 0:
            table = self.queryone(DataTable)
            totalrating = sum(float(row[5]) for row in table.rows)
            self.avgrating = round(totalrating / self.totalrows, 2)
            self.queryone("#card-rating", StatsCard).value = self.avgrating

    def updateprogress(self) -> None:
        progress = self.queryone(ProgressBar)
        progress.advance(1)
        if progress.progress >= 100:
            progress.progress = 0

Linking User Interactions to Application Logic

Finally, we bind UI events such as button presses and keyboard shortcuts to their corresponding backend functions. This integration ensures that user actions like adding rows, clearing data, or toggling themes immediately affect the dashboard’s state, delivering a smooth and interactive user experience.

    @on(Button.Pressed, "#btn-add")
    def handleaddbutton(self) -> None:
        nameinput = self.queryone("#input-name", Input)
        category = self.queryone("#select-category", Select).value

        if nameinput.value and category:
            table = self.queryone(DataTable)
            rowid = self.totalrows + 1
            price = round(random.uniform(15, 600), 2)
            sales = random.randint(5, 150)
            rating = round(random.uniform(1, 5), 1)

            table.addrow(
                str(rowid),
                nameinput.value,
                str(category),
                f"${price}",
                str(sales),
                str(rating)
            )

            self.totalrows += 1
            self.totalsales += sales
            self.updatestats()
            nameinput.value = ""

    @on(Button.Pressed, "#btn-clear")
    def handleclearbutton(self) -> None:
        table = self.queryone(DataTable)
        table.clear()
        self.totalrows = 0
        self.totalsales = 0
        self.avgrating = 0
        self.updatestats()

    @on(Button.Pressed, "#btn-generate")
    def handlegeneratebutton(self) -> None:
        self.generatesampledata(10)

    def actiontoggledark(self) -> None:
        self.dark = not self.dark

    def actionaddrow(self) -> None:
        self.handleaddbutton()

    def actioncleartable(self) -> None:
        self.handleclearbutton()


if name == "main":
    import nestasyncio
    nestasyncio.apply()
    app = DataDashboard()
    app.run()

Summary

This tutorial demonstrates how to assemble a fully interactive, terminal-based dashboard that runs seamlessly within a notebook environment. Leveraging Textual’s modern reactive UI capabilities, we build a polished interface that combines data visualization, user input, and real-time updates-all using pure Python. This foundation can be expanded with additional features such as charting libraries, live API integrations, and multi-page navigation to create even more powerful terminal applications.

More from this stream

Recomended