4. Styling and responsive layout

guestbook.py
"""Tutorial step 4: styles, a theme and a responsive layout."""

from dataclasses import dataclass, field
from itertools import count

from pydantic import BaseModel, Field

from rational import (
    App,
    Avatar,
    Button,
    Card,
    Component,
    Container,
    Form,
    Grid,
    Heading,
    Input,
    Page,
    Row,
    State,
    Style,
    Text,
    Textarea,
    js,
)

app = App(title="Guestbook", theme="soft")  # a preset: modern, minimal, soft or professional


_ids = count(1)


@dataclass(frozen=True)
class Entry:
    name: str
    message: str
    id: int = field(default_factory=lambda: next(_ids))


GUESTBOOK = [
    Entry("Ada", "What a lovely place!"),
    Entry("Grace", "Signed, sealed, compiled."),
]


class SignForm(BaseModel):
    name: str = Field(min_length=1, max_length=40)
    message: str = Field(min_length=1, max_length=280)


QUOTE = Style(font_size="lg", line_height=1.6)  # styles are values: define them once, reuse them
ACCENT = Style(border_left="4px solid var(--rw-color-primary)", hover=Style(shadow="md"), transition=True)


class EntryCard(Component):
    entry: Entry
    hearts = State(0)

    def like(self):
        self.hearts += 1

    def render(self):
        return Card(
            Text(self.entry.message, style=QUOTE),
            Row(
                Avatar(self.entry.name, size=28),
                Text(self.entry.name, muted=True, size="sm"),
                Button(
                    f"♥ {self.hearts}", variant="ghost", size="sm", on_click=self.like, style=Style(margin_left="auto")
                ),
            ),
            style=ACCENT,
        )


@app.page("/")
class Home(Page):
    title = "Guestbook"
    entries = State(factory=list)

    def mount(self):
        self.entries = list(GUESTBOOK)

    def sign(self, form: SignForm):
        GUESTBOOK.insert(0, Entry(form.name, form.message))
        self.entries = list(GUESTBOOK)

    def render(self):
        return Container(
            Row(
                Heading("Guestbook", level=1),
                Button("Dark mode", variant="outline", size="sm", on_click=js.toggle_theme()),  # no server trip
                justify="between",
            ),
            Row(
                Card(
                    Form(
                        Input("Your name", name="name", required=True),
                        Textarea("Message", name="message", required=True, rows=4),
                        Button("Sign the guestbook", type="submit", full_width=True),
                        on_submit=self.sign,
                        key=f"form-{len(self.entries)}",
                    ),
                    style=Style(width=320, shrink=0, viewport={"mobile": Style(width="100%")}),
                ),
                # As many columns as fit, at least 240px each: responsive without media queries.
                Grid(*[EntryCard(entry=e, key=e.id) for e in self.entries], min_column_width=240),
                align="start",
                collapse="mobile",  # side by side on wider screens, stacked on phones
            ),
            tag="main",
            max_width=1000,
            style=Style(layout="vertical", gap="lg", padding_y="xl"),
        )


if __name__ == "__main__":
    app.run()

Themes

App(theme="soft") picks a preset: modern, minimal, soft or professional. Themes define colours, spacing, radius, fonts and shadows as CSS variables, with light and dark variants. js.toggle_theme() switches between them in the browser, with no server round trip, and remembers the choice in a cookie.

Styles are values

Style(...) objects are immutable, so define them once and reuse them. a | b merges two styles, with b winning. Values can be theme tokens ("primary", "lg"), numbers (pixels) or any CSS value. Pseudo-states such as hover=Style(...) nest naturally.

Each element gets exactly one generated class, and identical styles share it. You never fight CSS specificity.

Responsive without media queries…

Grid(min_column_width=240) places as many columns as fit, each at least 240px wide. It adapts to any screen without a single breakpoint.

…and with them when you need them

Row(collapse="mobile") puts the form beside the messages on wider screens and stacks them on phones. viewport={"mobile": Style(width="100%")} changes a style below 640px. The names (mobile, tablet, narrow, desktop, sm … xl) are listed in Styling and themes.

Resize your browser window to watch the layout adapt.

Next: 5. A database, a service and an API.