Input & UX

Styling Textual Apps with TCSS

Lay out and style a Textual TUI with TCSS: external stylesheets, grid and fr units, theme variables, state classes instead of inline styles, light and dark themes, packaging and tests.

Updated

Textual apps are styled with TCSS, Textual's dialect of CSS: selectors, rules, a box model, and layout systems, applied to widgets instead of HTML elements. It is one of Textual's best ideas — appearance lives in a stylesheet, separate from the Python that defines behaviour — and it is easy to fight it by setting styles from code, hard-coding colours that break in a light terminal, or nesting containers where one grid rule would do. This guide styles a small service-status app the TCSS way: an external stylesheet, a grid layout with fractional columns, theme variables instead of fixed colours, state expressed as classes, light and dark themes, packaging the stylesheet with the CLI, and tests that check computed styles. It belongs to the Textual topic.

Prerequisites

  • Textual 1.0+ (checked with 8.2); pytest with pytest-asyncio in auto mode.
  • Familiarity with web CSS selectors helps; the differences are noted below.

How TCSS maps onto widgets

TCSS selectors and what they match Textual CSS selector types, an example of each and the widgets they match. TCSS selectors and what they match Selector Example Matches Type Static, DataTable widgets of that class ID #details id= set in compose Class .service.failed classes toggled from code Pseudo-class :focus, :hover widget state Theme variable $error, $primary colour from the active theme Sizes are terminal cells; fr units divide what is left.

Selectors work as in web CSS: a type selector (Static, DataTable) matches widget classes, an ID selector (#details) matches the id= given in compose, and a class selector (.failed) matches CSS classes on widgets. Pseudo-classes such as :focus and :hover reflect widget state. The main differences from the web: sizes are in terminal cells, fr units divide remaining space, colours can reference theme variables ($primary, $error, $surface), and layout is chosen per container with layout: vertical | horizontal | grid.

The recipe

Keep styles in a file next to the app, referenced with CSS_PATH:

/* src/mytool/status_app.tcss */
Screen {
    layout: grid;
    grid-size: 2;
    grid-columns: 2fr 1fr;
    grid-gutter: 1 2;
}

#services {
    border: round $primary;
    height: 100%;
}

#details {
    border: round $secondary;
    padding: 0 1;
}

.service {
    padding: 0 1;
    height: 1;
}

.service.failed {
    color: $error;
    text-style: bold;
}

.service:focus {
    background: $boost;
}
# src/mytool/status_app.py
from __future__ import annotations

from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Footer, Static


class Service(Static, can_focus=True):
    def __init__(self, name: str, status: str) -> None:
        super().__init__(f"{name}  {status}", classes="service")
        self.service_name = name
        self.set_class(status == "failed", "failed")


class StatusApp(App[None]):
    CSS_PATH = "status_app.tcss"
    BINDINGS = [("t", "toggle_dark", "Toggle theme")]

    def __init__(self, services: dict[str, str]) -> None:
        super().__init__()
        self.services = services

    def compose(self) -> ComposeResult:
        with VerticalScroll(id="services"):
            for name, status in self.services.items():
                yield Service(name, status)
        yield Static("select a service", id="details")
        yield Footer()

    def mark(self, name: str, status: str) -> None:
        for widget in self.query(Service):
            if widget.service_name == name:
                widget.update(f"{name}  {status}")
                widget.set_class(status == "failed", "failed")

    def action_toggle_dark(self) -> None:
        self.theme = "textual-light" if self.theme == "textual-dark" else "textual-dark"

Layout with a grid and fr units

layout: grid on the screen with grid-size: 2 and grid-columns: 2fr 1fr gives the service list two thirds of the width and the details pane one third, at any terminal size, without nested containers or hard-coded widths. grid-gutter adds spacing between cells. For a simple split, layout: horizontal with width: 2fr / width: 1fr on the children does the same; grids pay off when you have rows and columns.

State as classes, not inline styles

The failed state is a CSS class, toggled with set_class(condition, "failed"). The stylesheet decides what "failed" looks like; the Python only decides whether a service has failed. That keeps all visual decisions in one file, lets designers (or you, later) change them without touching logic, and makes state queryable in tests (widget.has_class("failed")). Avoid widget.styles.color = "red" in application code — it hard-codes a colour that may be unreadable in a light theme and cannot be overridden by the stylesheet.

Live-reloading a stylesheet Terminal session running a Textual app in development mode with stylesheet live reload and the Textual console for logs. Live-reloading a stylesheet bash $ uv add --dev textual-dev $ textual run --dev src/mytool/status_app.py # edit status_app.tcss and save — the running app restyles itself $ textual console # in a second terminal: logs and CSS errors A TCSS error names the file, line and rule.

Theme variables and light/dark

$primary, $secondary, $error, $surface, $boost and the other theme variables resolve to colours from the active theme. Switching app.theme between "textual-dark" and "textual-light" (or any registered theme) recolours the whole app, because nothing in the stylesheet names a literal colour. Users with light terminals — or who need higher contrast — then get a readable interface for free. If you must use a literal colour, check it against both themes.

Adapting to narrow terminals

TCSS has no media queries, but the same effect takes one class and one event handler. Add a rule for a narrow class on the screen that collapses the grid to one column:

Screen.narrow {
    grid-size: 1;
    grid-columns: 1fr;
}

and toggle the class whenever the terminal is resized:

from textual import events

# inside StatusApp
    def on_resize(self, event: events.Resize) -> None:
        self.screen.set_class(event.size.width < 80, "narrow")

On a 60-column terminal, the details pane now sits under the service list at full width instead of being squeezed into a third of the screen. The threshold lives in code, the layout in the stylesheet — the same split as the failed class. A Pilot test with run_test(size=(60, 30)) can check that the class is applied and that both panes have the same outer width.

Developing styles

textual run --dev src/mytool/status_app.py (from the textual-dev package) reloads the stylesheet when you save it, so layout tweaks show up immediately; textual console in another terminal shows logs and errors. TCSS errors are reported with the file, line and rule, and stop the app at startup — which is why a test that simply starts the app is worth having.

Packaging the stylesheet

CSS_PATH is resolved relative to the Python file that defines the app, so the .tcss file must be installed next to it. With a src/ layout and a backend that includes package data (uv_build and Hatchling do by default), keeping the stylesheet inside the package directory is enough; with setuptools, list it in package-data. Forgetting this produces an app that works from the source tree and fails after pipx install — exactly the kind of mistake bundling data files with importlib.resources and a wheel-contents test prevent.

UX considerations

  • Respect the user's theme. Use theme variables; offer a key or setting to switch light/dark, and remember the choice.
  • Make focus visible. A :focus rule on every focusable widget is essential for keyboard users — Textual's defaults do this for built-in widgets; custom ones need it.
  • Do not rely on colour alone. Pair $error with bold text, an icon or the word "failed", as this app does.
  • Design for 80×24. Test layouts at small sizes; fr units and grids degrade gracefully, fixed widths do not.
Styling the TCSS way Practices for styling Textual applications and habits that make styles brittle. Styling the TCSS way Do ✓ CSS_PATH stylesheet in the package ✓ Grid and fr units for layout ✓ Theme variables for every colour ✓ Classes for state, set_class() in code Avoid ✗ widget.styles.color = "red" in logic ✗ Hard-coded widths for panes ✗ Colour as the only signal ✗ Forgetting the .tcss in the wheel Python decides what the app does; the stylesheet decides how it looks.

Testing the behaviour

Computed styles and layout sizes are available in Pilot tests, so you can check the rules that carry meaning without screenshot comparisons:

# tests/test_styles.py
from mytool.status_app import Service, StatusApp

SERVICES = {"api": "ok", "worker": "failed"}


async def test_failed_class_applies_error_style():
    app = StatusApp(SERVICES)
    async with app.run_test():
        api, worker = list(app.query(Service))
        assert worker.has_class("failed") and not api.has_class("failed")
        assert worker.styles.color != api.styles.color
        assert worker.styles.text_style.bold


async def test_status_change_updates_class():
    app = StatusApp(SERVICES)
    async with app.run_test() as pilot:
        app.mark("api", "failed")
        await pilot.pause()
        assert list(app.query(Service))[0].has_class("failed")


async def test_grid_gives_services_two_thirds():
    app = StatusApp(SERVICES)
    async with app.run_test(size=(90, 20)):
        assert app.query_one("#services").size.width > app.query_one("#details").size.width * 1.5


async def test_theme_toggle():
    app = StatusApp(SERVICES)
    async with app.run_test() as pilot:
        before = app.theme
        await pilot.press("t")
        assert app.theme != before

Simply starting the app in a test also validates the stylesheet: a TCSS syntax error fails run_test() immediately. For pixel-level appearance, snapshot testing with pytest-textual-snapshot, as described in testing Textual apps with Pilot, complements these behavioural checks.

Conclusion

TCSS keeps a Textual app's appearance where it belongs: in a stylesheet next to the code, referenced with CSS_PATH and packaged with it. Use a grid with fr units for layout, theme variables instead of literal colours so light and dark themes both work, classes toggled from code to express state, visible focus styles, and tests that check classes, computed styles and sizes. The Python then describes what the app does, and the stylesheet describes how it looks.

Frequently asked questions

Can I put CSS inline in the app instead of a file?

Yes — the CSS class variable takes a string, which is convenient for tiny apps and examples. Move to CSS_PATH once the stylesheet grows past a few rules, so it gets syntax highlighting and live reload.

Do web CSS properties work in TCSS?

Many concepts carry over (margin, padding, border, color, background, display), but TCSS has its own property set and units suited to terminals. Check the Textual style reference rather than assuming a web property exists.

How do I style a widget differently on one screen?

Use a screen-scoped selector (MainScreen #details { … }) or give the screen its own CSS_PATH. Scoping keeps styles for one screen from leaking into others.

Can users customise the colours?

Register custom themes and let users choose by name in your configuration file; because the stylesheet only references theme variables, a new theme restyles everything without code changes.

Can several apps share one stylesheet?

Yes — CSS_PATH accepts a list of files, so a CLI with several TUIs can share a base stylesheet (colours, focus styles, common widgets) and add a small app-specific file for each layout.