--- name: nicegui-ui-customization description: Style, customize, and build interactive UIs in a production-ready NiceGUI app. --- # NiceGUI UI Customization & Component Patterns Customize, style, and build interactive user interfaces in a production-ready NiceGUI application. This skill covers component architecture, styling systems, event-driven interactions, and practical troubleshooting for production deployments. ## Goal Create a well-architected, responsive, accessible UI that: - Separates structure (Python layout) from cosmetics (CSS styling) - Handles user interactions via event-driven patterns, not polling - Composes reusable components with clear responsibilities - Integrates custom styling and assets cleanly - Troubleshoots common failures (race conditions, asset caching, upload errors) ## Project Structure & Conceptual Boundaries This skill assumes a project organized with clear, modular boundaries: ```text src/app/ ├─ ui/ │ ├─ pages/ # Page-level UI modules (route handlers) │ │ ├─ __init__.py │ │ ├─ home.py │ │ ├─ dashboard.py │ │ └─ about.py │ ├─ components/ # Reusable presentation components │ │ ├─ __init__.py │ │ ├─ nav.py │ │ ├─ card_section.py │ │ └─ form_section.py │ └─ static/ # Static assets (CSS, JS, images) │ ├─ css/ │ │ ├─ base.css │ │ ├─ theme.css │ │ └─ components.css │ ├─ js/ │ └─ images/ ├─ services/ # Business logic layer │ ├─ __init__.py │ ├─ file_service.py │ ├─ user_service.py │ └─ ... ├─ api/ # HTTP endpoints │ ├─ __init__.py │ ├─ health.py │ └─ routes.py ├─ bootstrap.py # App composition, CSS/asset setup ├─ config.py # Settings and configuration └─ ... ``` **Key boundaries**: - **`ui/pages/`**: Route-level adapters that call `services/` - **`ui/components/`**: Reusable presentation components (no business logic) - **`ui/static/`**: Assets loaded at startup in `bootstrap.py` - **`services/`**: Business logic (independent of UI) - **`api/`**: HTTP endpoints **Dependency flow**: `pages/` → `components/` + `services/` (no reverse imports) --- ## UI Component Patterns Components are the building blocks of reusable UI. They live in `ui/components/` and are consumed by pages in `ui/pages/`. Each component maps to [NiceGUI elements](https://nicegui.io/documentation/element), which expose a consistent API for structure, styling, and event handling. ### Reusable Components Extract to `ui/components/` when patterns repeat across pages. Keep in-page layouts unique to that page. ```python # ui/components/card_section.py def card_section(title: str, content: str) -> ui.card: with ui.card().classes("w-full max-w-md") as card: ui.label(title).classes("text-lg font-bold") ui.label(content).classes("text-gray-600") return card ``` ### Layout Patterns Use Tailwind for all structural styling. Reserve `.style()` for computed values. ```python with ui.column().classes("w-full"): with ui.row().classes("w-full gap-4 flex-wrap sm:flex-nowrap"): ui.card().classes("flex-1 min-w-64") ui.card().classes("flex-1 min-w-64") ``` ### State Management Use [bindable dataclasses](https://nicegui.io/documentation/section_binding_properties) for automatic two-way binding. ```python from nicegui import binding, ui from dataclasses import field @binding.bindable_dataclass class PageState: selected_id: int | None = None items: list = field(default_factory=list) state = PageState() detail_label = ui.label().bind_text_from(state, 'selected_id') def on_select(item_id): state.selected_id = item_id # Auto-updates UI state.bind_on_change(lambda: load_detail(), 'selected_id') ``` --- ## Styling Use Tailwind for all styling. Use breakpoint prefixes (`sm:`, `md:`, `lg:`) for responsive design. ### Custom CSS Injection ⚠️ Avoid This Pattern **Avoid injecting custom CSS** via `ui.add_css()` at app startup. Custom CSS injection creates maintenance burden, breaks IDE autocomplete, and makes styling harder to reason about. Instead: **✅ Prefer these approaches**: 1. **Use [Tailwind utility classes](https://tailwindcss.com/docs/utility-first)** — covers 95% of styling needs with zero CSS overhead. ```python with ui.column().classes("w-full bg-gradient-to-br from-gray-100 to-gray-200"): with ui.card().classes("rounded-lg border border-gray-300 shadow-md hover:shadow-lg transition-shadow"): ui.label("Content") ``` 2. **Use [Quasar props](https://quasar.dev/vue-components)** — high-level component theming built into NiceGUI elements. ```python ui.button("Submit").props("color=primary size=lg") ``` 3. **Extract reusable components** — encapsulate styling patterns in Python functions rather than CSS classes. ```python def styled_card(title: str, content: str) -> ui.card: with ui.card().classes("rounded-lg border border-gray-300 shadow-md") as card: ui.label(title).classes("text-lg font-bold") ui.label(content).classes("text-gray-600") return card ``` **If you absolutely must inject CSS** (rare edge cases only): - Store CSS in `src/app/static/css/` files - Load via `ui.add_css(open('src/app/static/css/custom.css').read())` in `bootstrap.py` - Keep injected CSS minimal and well-documented - Avoid hardcoding colors or spacing; use CSS variables instead ### Static Assets Load CSS files at app startup in `bootstrap.py`: ```python from fastapi.staticfiles import StaticFiles app.mount("/static", StaticFiles(directory="src/app/static"), name="static") ui.add_css(open('src/app/static/css/base.css').read()) ``` Use CSS variables for design tokens: ```css :root { --color-primary: #3b82f6; --spacing-unit: 1rem; } ``` ### JS Injection (Rare) Use `ui.run_javascript()` only for unsupported interactions. Prefer [NiceGUI APIs](https://nicegui.io/documentation/section_action_events) first. --- ## Event-Driven Patterns NiceGUI provides event-driven APIs for [actions and events](https://nicegui.io/documentation/section_action_events). This section covers common patterns: file uploads, form submissions, WebSocket/SSE for real-time updates, and background tasks. ### File Upload Validate and notify; delegate storage to service layer. ```python async def handle_upload(e: ui.events.UploadEventArguments): try: if e.size > 10 * 1024 * 1024: raise ValueError("File too large") if not e.name.endswith('.pdf'): raise ValueError("Only PDF allowed") await file_service.store(e.content.read(), e.name) ui.notify(f"Uploaded: {e.name}", type="positive") except ValueError as err: ui.notify(str(err), type="negative") ui.upload(on_upload=handle_upload, auto_upload=True) ``` ### Form Submission Bind form inputs to dataclass; validate in service. ```python @binding.bindable_dataclass class FormData: name: str = "" email: str = "" data = FormData() ui.input("Name").bind_value(data, 'name') ui.input("Email").bind_value(data, 'email') async def on_submit(): try: await user_service.create_user(name=data.name, email=data.email) ui.notify("User created", type="positive") data.name = data.email = "" except ValueError as err: ui.notify(str(err), type="negative") ui.button("Submit").on_click(on_submit) ``` ### Real-Time Updates (WebSocket / SSE) For streaming updates, use [SSE](https://fastapi.tiangolo.com/advanced/server-sent-events/) (one-way) or [WebSocket](https://fastapi.tiangolo.com/advanced/websockets/) (bidirectional). ```python # api/events.py @app.get("/events/status") async def status_stream(): async def gen(): while True: yield f"data: {await get_status()}\\n\\n" await asyncio.sleep(1) return StreamingResponse(gen(), media_type="text/event-stream") # Page status_label = ui.label() async def listen(): async with aiohttp.ClientSession() as s: async with s.get("/api/events/status") as r: async for line in r.content: if line.startswith(b"data:"): status_label.set_text(line.decode().replace("data:", "").strip()) ui.timer(0.1, lambda: asyncio.create_task(listen())) ``` ### Background Tasks Delegate long-running work to background tasks; signal UI via polling or WebSocket. ```python # API @app.post("/process") async def process(background_tasks: BackgroundTasks): background_tasks.add_task(long_job) return {"status": "processing"} # Page async def on_start(): await client.post("/api/process") ui.notify("Started") async def check(): status = await client.get("/api/process/status") if status == "done": ui.notify("Complete", type="positive") ui.button("Start").on_click(on_start) ui.timer(1, check) ``` ### Reactive Refresh Use `@ui.refreshable` with explicit refresh triggers; avoid polling everything. ```python @ui.refreshable async def item_list(): items = await service.list() for item in items: ui.label(item.name) ui.button("Refresh").on_click(lambda: item_list.refresh()) ``` --- ## Advanced Interactions ### Dialogs ```python dialog = ui.dialog() async def show_confirm(): with dialog: ui.label("Are you sure?") ui.button("Yes").on_click(lambda: (confirm_action(), dialog.close())) ui.button("No").on_click(dialog.close) dialog.open() ui.button("Delete").on_click(show_confirm) ``` ### Progress ```python progress = ui.linear_progress(value=0).classes("w-full") async def handle_upload(e): for chunk in read_chunks(e.content): progress.set_value(uploaded / total) ui.notify("Complete", type="positive") ``` ### Drag & Drop Use [Quasar q-sortable](https://quasar.dev/vue-components/sortable) for native drag-and-drop support via `.props()`. --- ## Troubleshooting ### Upload Validation & Errors - Validate file size/extension before storage - Catch exceptions and emit `ui.notify()` with type "negative" - Log errors for debugging ### Race Conditions - Serialize updates: `await service.get_data()` before UI update - Avoid multiple timers on same component - Use `button.enabled = False/True` to prevent duplicate submissions ### Asset Caching - Inject CSS in `bootstrap.py` at startup, not per-page - Clear browser cache (Ctrl+Shift+Del) or use cache-busting query params: `?v=hash` - Verify `/static/` mount path and nginx proxy rewrites ### Navigation & State - Don't store state in globals; keep in `services/` or request-scoped - Use `ui.navigate.to()` to switch pages - Reload data in destination page's `@ui.page()` decorator