Style and layout Textual widgets using CSS-like syntax for responsive design. Use when implementing responsive layouts, CSS styling, color schemes, spacing, sizing, alignment, grid layouts,...
Master Textual's CSS-like styling system for building responsive, visually polished TUI applications. Textual styling supports CSS concepts adapted for terminal environments.
from textual.widgets import Static
from textual.containers import Container, Vertical, Horizontal
class StyledWidget(Container):
"""Widget with comprehensive styling."""
CSS = """
Screen {
layout: vertical;
background: $surface;
}
#header {
height: 3;
background: $boost;
text-style: bold;
}
#content {
height: 1fr;
border: solid $primary;
padding: 1;
}
#footer {
height: auto;
border-top: solid $primary;
padding: 1;
}
"""
def compose(self) -> ComposeResult:
"""Compose layout."""
yield Static("Header", id="header")
yield Static("Content", id="content")
yield Static("Footer", id="footer")
Learn the CSS properties available in Textual:
# Layout properties
width: 100% | 50 | 1fr | auto
height: 100% | 10 | 1fr | auto
layout: vertical | horizontal | grid
# Spacing
padding: 1 # All sides
padding: 1 2 # Vertical Horizontal
padding: 1 2 3 4 # Top Right Bottom Left
margin: 1 # All sides
margin: 1 2 # Vertical Horizontal
# Borders
border: solid $primary # border: {style} {color}
border-left: solid $primary
border-right: dashed $error
border-top: double $success
border-bottom: solid $warning
# Styles: solid, dashed, double, thick, tall, wide
# Background and foreground colors
background: $surface
color: $text
background: #ff0000 (hex)
color: rgb(255, 0, 0)
# Text styling
text-style: bold
text-style: italic
text-style: underline
text-style: bold italic underline
text-style: dim # Dimmed/faded
# Alignment
align: left | center | right
align-horizontal: left | center | right
align-vertical: top | middle | bottom
content-align: center middle # Shorthand for both
# Opacity
opacity: 1.0 # 0.0 (transparent) to 1.0 (opaque)
# Display
display: block | none # Hide widget if 'none'
# Offset
offset: 1 2 # x y offset from position
# Text overflow
text-overflow: fold | crop | ellipsis
overflow: auto | hidden # x y overflow for containers
# Layers (stacking)
layer: overlay # Stacking order
z-index: 1 # Numeric layer order
Add DEFAULT_CSS to widgets for styling:
from textual.widgets import Static
from textual.containers import Vertical, Horizontal
class FormWidget(Vertical):
"""Form with styled fields."""
DEFAULT_CSS = """
FormWidget {
height: auto;
width: 50;
border: solid $primary;
padding: 1;
background: $surface;
}
FormWidget > Static {
width: 100%;
}
FormWidget .label {
text-style: bold;
color: $text;
margin: 0 0 0 0;
}
FormWidget Input {
width: 100%;
height: 3;
margin: 0 0 1 0;
}
FormWidget Button {
width: 100%;
margin-top: 1;
}
FormWidget Button:focus {
background: $accent;
}
"""
def compose(self) -> ComposeResult:
yield Static("Username", classes="label")
yield Input(id="username")
yield Static("Password", classes="label")
yield Input(id="password", password=True)
yield Button("Login")
CSS Inline vs External:
DEFAULT_CSS - String in widget class.tcss file - Can be loaded with CSS_PATH = "file.tcss"CSS - In App class for global stylesLeverage Textual's color system:
from textual.app import App
class ThemedApp(App):
"""App with colors and themes."""
# Select theme
THEME = "dracula" # Built-in themes:
# nord, dracula, monokai, solarized-dark,
# solarized-light, one-dark, one-light, etc.
CSS = """
Screen {
background: $surface; # Surface color
color: $text; # Text color
}
.header {
background: $boost; # Boost (lighter surface)
color: $text;
}
.success {
color: $success; # Green
}
.error {
color: $error; # Red
}
.warning {
color: $warning; # Yellow
}
.info {
color: $info; # Blue
}
.accent {
color: $accent; # Accent color
}
.primary {
color: $primary; # Primary color
border: solid $primary;
}
.muted {
color: $text-muted; # Muted text
text-style: dim;
}
"""
Color Variables:
$primary - Primary accent color$secondary - Secondary accent color$accent - Accent color$success - Success (green)$error - Error (red)$warning - Warning (yellow)$info - Info (blue)$surface - Background surface$boost - Lighter background$panel - Panel background$text - Primary text color$text-muted - Muted textBuilt-in Themes:
python -m textualCreate layouts that adapt to window size:
from textual.containers import Vertical, Horizontal, Container
from textual.widgets import Static
class ResponsiveLayout(Container):
"""Layout adapting to screen size."""
CSS = """
ResponsiveLayout {
height: 100%;
width: 100%;
}
ResponsiveLayout > Vertical {
width: 1fr;
height: 1fr;
}
ResponsiveLayout > Horizontal {
width: 1fr;
height: 1fr;
}
/* On small screens (< 80 columns) - stacked layout */
@media (max-width: 80) {
ResponsiveLayout {
layout: vertical;
}
ResponsiveLayout > #sidebar {
width: 100%;
height: auto;
border-bottom: solid $primary;
}
ResponsiveLayout > #content {
width: 100%;
height: 1fr;
}
}
/* On large screens (>= 80 columns) - side-by-side layout */
@media (min-width: 80) {
ResponsiveLayout {
layout: horizontal;
}
ResponsiveLayout > #sidebar {
width: 25%;
height: 100%;
border-right: solid $primary;
}
ResponsiveLayout > #content {
width: 75%;
height: 100%;
}
}
"""
def compose(self) -> ComposeResult:
yield Vertical(
Static("Sidebar", id="sidebar-title"),
Static("Navigation items here"),
id="sidebar",
)
yield Vertical(
Static("Main content", id="content-title"),
Static("Content area"),
id="content",
)
Media Queries:
@media (condition) {
/* CSS rules for condition */
}
Conditions:
- (max-width: N) - Maximum width in cells
- (min-width: N) - Minimum width in cells
- (max-height: N) - Maximum height in cells
- (min-height: N) - Minimum height in cells
- (width: N) - Exact width
- (height: N) - Exact height
Use CSS Grid for complex layouts:
from textual.containers import Container
from textual.widgets import Static
class GridLayout(Container):
"""Grid-based layout."""
CSS = """
GridLayout {
layout: grid;
grid-size: 3 3; # 3 columns, 3 rows
grid-gutter: 1 2; # vertical horizontal gutter
width: 100%;
height: 100%;
}
GridLayout > Static {
border: solid $primary;
content-align: center middle;
}
#item1 { grid-column: 1; grid-row: 1; }
#item2 { grid-column: 2; grid-row: 1; }
#item3 { grid-column: 3; grid-row: 1; }
#item4 { grid-column: 1 / 3; grid-row: 2; } /* Span 2 columns */
#item5 { grid-column: 3; grid-row: 2 / 4; } /* Span 2 rows */
"""
def compose(self) -> ComposeResult:
for i in range(1, 6):
yield Static(f"Item {i}", id=f"item{i}")
Grid Properties:
grid-size: cols rows # Grid dimensions
grid-gutter: v h # Space between items
grid-column: start [/ end] # Column position/span
grid-row: start [/ end] # Row position/span
Style variants with CSS classes:
from textual.widgets import Button, Static
class VariantWidget(Static):
"""Widget with CSS class variants."""
DEFAULT_CSS = """
VariantWidget Button {
margin: 0 1;
}
VariantWidget Button.primary {
background: $primary;
color: $text;
}
VariantWidget Button.success {
background: $success;
color: $text;
}
VariantWidget Button.danger {
background: $error;
color: $text;
}
VariantWidget Button:focus {
background: $accent;
text-style: bold;
}
VariantWidget Button:disabled {
opacity: 0.5;
}
VariantWidget .muted {
color: $text-muted;
text-style: dim;
}
"""
def compose(self) -> ComposeResult:
yield Button("Primary", classes="primary")
yield Button("Success", classes="success")
yield Button("Danger", classes="danger")
yield Static("Muted text", classes="muted")
# Apply classes from Python
button = Button("Click me")
button.add_class("primary") # Add class
button.remove_class("primary") # Remove class
button.toggle_class("primary") # Toggle class
button.has_class("primary") # Check if has class
Pseudo-Classes:
:focus - Widget has focus
:hover - Mouse over (terminal dependent)
:disabled - Widget is disabled
:dark - Dark theme active
:light - Light theme active
from textual.app import App, ComposeResult
from textual.containers import Container, Vertical, Horizontal
from textual.widgets import Static, Header, Footer
class DashboardApp(App):
"""Styled dashboard application."""
CSS = """
Screen {
layout: vertical;
background: $surface;
}
Header {
height: 1;
background: $boost;
dock: top;
}
Footer {
height: auto;
background: $boost;
dock: bottom;
}
.main-container {
height: 1fr;
layout: horizontal;
padding: 0;
}
.sidebar {
width: 25%;
height: 1fr;
border-right: solid $primary;
background: $boost;
padding: 1;
}
.content {
width: 75%;
height: 1fr;
padding: 1;
overflow: auto;
}
.section-header {
text-style: bold;
color: $primary;
margin: 1 0 0 0;
}
.stat-box {
width: 1fr;
height: auto;
border: solid $primary;
padding: 1;
margin: 0 1 1 0;
}
.stat-value {
color: $success;
text-style: bold;
}
.stat-label {
color: $text-muted;
text-style: dim;
}
@media (max-width: 80) {
.main-container {
layout: vertical;
}
.sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: solid $primary;
}
.content {
width: 100%;
height: 1fr;
}
}
"""
def compose(self) -> ComposeResult:
yield Header()
with Container(classes="main-container"):
with Vertical(classes="sidebar"):
yield Static("Navigation", classes="section-header")
yield Static("● Dashboard")
yield Static("● Agents")
yield Static("● Settings")
with Vertical(classes="content"):
yield Static("Dashboard", classes="section-header")
# Statistics grid
yield Static("Stat 1\n", classes="stat-box")
yield Static("Stat 2\n", classes="stat-box")
yield Footer()
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Static, Input, Button, Label
class FormWidget(Vertical):
"""Styled form with validation feedback."""
CSS = """
FormWidget {
width: 60;
height: auto;
border: solid $primary;
padding: 1;
background: $surface;
}
FormWidget .form-header {
width: 100%;
height: 3;
content-align: center middle;
background: $boost;
text-style: bold;
margin: 0 0 1 0;
}
FormWidget .form-field {
width: 100%;
margin: 0 0 1 0;
}
FormWidget .field-label {
text-style: bold;
color: $text;
margin: 0 0 0 0;
height: 1;
}
FormWidget Input {
width: 100%;
height: 3;
}
FormWidget .field-error {
color: $error;
text-style: dim;
height: 1;
display: none; /* Hidden by default */
}
FormWidget Input.invalid {
border: solid $error;
}
FormWidget Input.invalid ~ .field-error {
display: block; /* Show error when field invalid */
}
FormWidget .form-footer {
width: 100%;
height: auto;
margin-top: 1;
layout: horizontal;
}
FormWidget Button {
width: 1fr;
margin: 0 1 0 0;
}
FormWidget Button.submit {
background: $success;
}
FormWidget Button.cancel {
background: $error;
}
"""
def compose(self) -> ComposeResult:
yield Static("Login", classes="form-header")
with Vertical(classes="form-field"):
yield Static("Email", classes="field-label")
yield Input(id="email-input")
yield Static("Invalid email address", classes="field-error")
with Vertical(classes="form-field"):
yield Static("Password", classes="field-label")
yield Input(id="password-input", password=True)
yield Static("Password too short", classes="field-error")
with Horizontal(classes="form-footer"):
yield Button("Login", classes="submit", variant="primary")
yield Button("Cancel", classes="cancel")
1. Use CSS variables for consistency:
# ✅ GOOD - Uses theme colors
.header {
background: $boost;
color: $text;
border: solid $primary;
}
# ❌ WRONG - Hardcoded colors
.header {
background: #1e1e1e;
}
2. Organize CSS logically:
CSS = """
/* Layout structure */
Screen { layout: vertical; }
/* Component styling */
.card { border: solid $primary; }
/* State variants */
.card.active { background: $boost; }
/* Responsive adjustments */
@media (max-width: 80) { }
"""
3. Use responsive design:
# Avoid fixed widths that don't fit terminals
# ❌ WRONG
.sidebar { width: 30; } # Too wide for small terminals
# ✅ CORRECT - Uses fraction units
.sidebar { width: 25%; } # Responsive to screen size