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-asyncioin auto mode. - Familiarity with web CSS selectors helps; the differences are noted below.
How TCSS maps onto widgets
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.
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
:focusrule 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
$errorwith bold text, an icon or the word "failed", as this app does. - Design for 80×24. Test layouts at small sizes;
frunits and grids degrade gracefully, fixed widths do not.
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.