Skip to content

URL-Backed Tabs with Sub Pages

The tab_spa.py example combines NiceGUI tabs with sub-page routing. It keeps one application shell and one set of tab panels mounted while the browser URL identifies the active view.

The example targets NiceGUI 3.16.0. In this design, tabs are the visible navigation and content mechanism; ui.sub_pages is a URL-matching adapter whose route builders update the tab state instead of rendering route content inside the router.

Responsibility Map

Surface Responsibility
root() Creates one client-local shell, reads the initial URL, and connects the tabs, panels, and router.
ui.tabs Holds the selected tab name and emits user selection changes.
ui.tab_panels Displays the panel whose name matches the selected tab.
ui.sub_pages Matches URL paths, extracts route parameters, and invokes the corresponding route callback without a full page reload.
NavigationState Retains the concrete report path represented by the shared reports tab.
ui.navigate.to() Changes the browser location so the sub-pages router can resolve the destination.

The separation matters because a tab name is not always a URL. Static tabs use their route as their name, but every /reports/{report_id} URL maps to the single reports tab and panel.

Route and Panel Mapping

Browser path Tab value Panel value Route callback effect
/ / / Selects the overview panel.
/projects /projects /projects Selects the projects panel.
/reports/a reports reports Stores /reports/a and selects the reports panel.
/reports/b reports reports Stores /reports/b and selects the reports panel.
/settings /settings /settings Selects the settings panel.

tab_name_for_route() is the translation boundary. It preserves static route names, collapses concrete report routes to reports, and returns / for other paths.

Initial Page Construction

root() reads ui.context.client.sub_pages_router.current_path before creating the navigation controls. normalize_route() removes query strings, fragments, and trailing slashes so /projects/ and /projects select the same tab.

For a direct request to /reports/b, the initial route produces two values:

  • active_report_path becomes /reports/b.
  • the selected tab and panel become reports.

The initial tabs.set_value(...) call occurs before tabs.on_value_change(navigate) is registered. Initial selection therefore establishes the shell state without treating page construction as a user navigation. Passing the same initial tab value to ui.tab_panels aligns the content container with the tabs from the first render.

All panel builders run during shell construction. Switching tabs changes the selected panel; it does not rerun overview_page(), projects_page(), report_page(), or settings_page(). Their element state remains client-local for the lifetime of that shell.

Tab-Originated Navigation

The tab change handler receives the selected tab name. Static tab names are already destinations. The reports tab resolves through state.active_report_path, which supplies the last concrete report URL:

destination = state.active_report_path if tabname == REPORTS_TAB else tabname
ui.navigate.to(destination)

ui.navigate.to() performs the route transition. The sub-pages router then matches the new location and invokes a callback that selects the corresponding tab. Because the panel container is associated with tabs, the visible panel follows that selected value.

URL-Originated Navigation

The route callbacks contain no page markup. They translate router matches back into the visible state:

def route_reports(report_id: str) -> None:
    state.active_report_path = f"/reports/{report_id}"
    tabs.set_value(REPORTS_TAB)

This direction handles direct links and browser back or forward navigation. A URL such as /reports/b supplies report_id="b"; the callback reconstructs the normalized concrete path, updates report-bound labels through NavigationState, and selects the shared reports panel.

The router element is hidden because it is not the content container in this example:

ui.sub_pages(routes).classes("hidden")

Normally, ui.sub_pages clears and rebuilds its own children when a route changes. Here its builders only mutate state outside that container, so hiding the empty routing element does not hide the tab-panel content.

Parameterized Report State

NavigationState.active_report_path separates tab identity from route identity. The reports tab always has the stable value reports, while the state records /reports/a, /reports/b, or another matched report route.

This provides two forms of continuity:

  • A direct report URL selects the correct tab and report during initial construction.
  • Leaving the reports tab and selecting it again during the same client lifetime returns to the last visited report.

The state is page-local, not durable storage. Reloading a non-report URL creates a new NavigationState and restores DEFAULT_REPORT_PATH. Shareable report identity remains durable because report pages encode it in the URL.

Behavioral Boundaries

  • TAB_ROUTES contains concrete routes whose path and tab identity are the same. Parameterized route families require a stable synthetic tab name such as reports.
  • normalize_route() intentionally ignores query parameters and fragments for tab selection. Route callbacks would need matching parameters if those values affected panel state.
  • The hidden router also hides its built-in 404 output. As written, an unmatched path selects the overview panel through tab_name_for_route() while the router's not-found content remains invisible.
  • Panels are mounted together, so expensive panel construction still occurs during the initial shell build. Lazy or route-specific construction requires a different content ownership model.
  • The pattern preserves the shell only for navigation handled by the current ui.sub_pages router. A full reload creates a new client and rebuilds all page-local state.

Source Index