Tabbed Profile¶
🚀 What you'll build: a profile screen with three sections — Overview, Activity and Settings — navigable via tabs, with Switch toggles that update preferences in real time.
Why this example matters¶
Every real application needs to organise content into sections without overwhelming the user.
TabView solves exactly that: it presents a set of labels at the top and swaps the screen
body as the selected tab changes.
In this tutorial you will learn how to:
- Use
TabViewfor section navigation; - Respond to
RouteChangeEventto update the tab index in state; - Use
SwitchwithToggleEventfor boolean controls (notifications, dark mode); - Compose rich layouts with
Card,Avatar,ListTileandDivider.
Note
This example runs without any modification in both modes — WASM (Pyodide in the
browser) and Server (FastAPI + WebSocket). The same Python view() serves both.
Prerequisites¶
Install tempestweb and confirm the CLI is available:
Project structure¶
Create the folder and file:
Step 1 — Define the state¶
The state holds which tab is active and the two boolean preferences (notifications and dark mode). A second dataclass models each entry in the activity list.
from __future__ import annotations
from dataclasses import dataclass, field
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import (
AppBar,
Avatar,
Card,
Divider,
ListTile,
Scaffold,
)
from tempestweb._core.style import AlignItems, Edge, FontWeight
from tempestweb._core.widgets import (
Column,
Row,
Switch,
TabView,
Text,
)
from tempestweb._core.widgets.events import RouteChangeEvent, ToggleEvent
_TABS: list[str] = ["Overview", "Activity", "Settings"]
@dataclass
class ActivityEntry:
"""A single item in the user's recent-activity feed.
Attributes:
title: Short description of the activity.
subtitle: Timestamp or context string.
"""
title: str
subtitle: str
@dataclass
class ProfileState:
"""All mutable state for the tabbed-profile screen.
Attributes:
active_tab: Index of the currently visible tab (0 = Overview, etc.).
notifications_on: Whether push notifications are enabled.
dark_mode: Whether the user has chosen dark-mode.
activity: Ordered list of recent activity entries.
"""
active_tab: int = 0
notifications_on: bool = True
dark_mode: bool = True
activity: list[ActivityEntry] = field(default_factory=list)
def make_state() -> ProfileState:
"""Build the initial profile state with seed activity entries.
Returns:
A fresh :class:`ProfileState` ready for the first render.
"""
return ProfileState(
activity=[
ActivityEntry("Joined the platform", "2 years ago"),
ActivityEntry("Completed onboarding", "2 years ago"),
ActivityEntry("Published first project", "18 months ago"),
ActivityEntry("Reached 100 followers", "1 year ago"),
ActivityEntry("Earned contributor badge", "6 months ago"),
]
)
Tip
_TABS is a simple list of strings. TabView uses these labels to render the tab
buttons — no extra configuration needed.
What just happened:
_TABSdefines the three labels thatTabViewwill display.ProfileState.active_tabis the single source of truth about which tab is visible.make_state()provides initial activity data so the Activity tab is already populated on the very first render.
Step 2 — Build the Overview section¶
The first tab shows an avatar, a bio, and a stats card.
def _overview_section(state: ProfileState) -> Widget:
"""Render the Overview tab: avatar, bio card, and stats row.
Args:
state: The current application state.
Returns:
A ``Column`` composing the overview content.
"""
notifications_label = "on" if state.notifications_on else "off"
dark_label = "dark" if state.dark_mode else "light"
return Column(
key="overview",
style=Style(gap=16.0, padding=Edge.all(16.0)),
children=[
Row(
key="profile-header",
style=Style(gap=16.0, align=AlignItems.CENTER),
children=[
Avatar(initials="AJ", size=72.0, key="avatar-lg"),
Column(
key="name-block",
style=Style(gap=4.0),
children=[
Text(
content="Alex Johnson",
key="full-name",
style=Style(
font_size=22.0,
font_weight=FontWeight.BOLD,
),
),
Text(
content="@alexj · Senior Engineer",
key="handle",
style=Style(font_size=14.0),
),
],
),
],
),
Card(
key="bio-card",
children=[
Text(
content="Bio",
key="bio-heading",
style=Style(
font_size=16.0,
font_weight=FontWeight.BOLD,
),
),
Text(
content=(
"Building developer tools and open-source libraries. "
"Passionate about clean architecture and great UX."
),
key="bio-body",
style=Style(font_size=14.0),
),
],
),
Card(
key="stats-card",
children=[
Text(
content="Quick stats",
key="stats-heading",
style=Style(
font_size=16.0,
font_weight=FontWeight.BOLD,
),
),
Divider(key="stats-divider"),
Row(
key="stats-row",
style=Style(gap=24.0),
children=[
Column(
key="stat-projects",
style=Style(gap=2.0, align=AlignItems.CENTER),
children=[
Text(
content="34",
key="stat-projects-val",
style=Style(
font_size=20.0,
font_weight=FontWeight.BOLD,
),
),
Text(
content="Projects",
key="stat-projects-lbl",
style=Style(font_size=12.0),
),
],
),
Column(
key="stat-followers",
style=Style(gap=2.0, align=AlignItems.CENTER),
children=[
Text(
content="1.2k",
key="stat-followers-val",
style=Style(
font_size=20.0,
font_weight=FontWeight.BOLD,
),
),
Text(
content="Followers",
key="stat-followers-lbl",
style=Style(font_size=12.0),
),
],
),
Column(
key="stat-stars",
style=Style(gap=2.0, align=AlignItems.CENTER),
children=[
Text(
content="892",
key="stat-stars-val",
style=Style(
font_size=20.0,
font_weight=FontWeight.BOLD,
),
),
Text(
content="Stars",
key="stat-stars-lbl",
style=Style(font_size=12.0),
),
],
),
],
),
Text(
content=(
f"Notifications: {notifications_label}"
f" | Theme: {dark_label}"
),
key="prefs-summary",
style=Style(font_size=12.0),
),
],
),
],
)
Info
The Divider inside the stats card is a simple horizontal visual separator —
no required parameters beyond key.
Highlights:
Avatar(initials="AJ", size=72.0)— the pre-built component renders a circle with the initials; no image URL handling needed.Edge.all(16.0)— applies uniform 16 px padding on all sides.AlignItems.CENTER— vertically aligns the avatar and the name block.- The preferences summary at the bottom (
prefs-summary) already reflects the Settings state even when you are on a different tab — because everything shares the sameProfileState.
Step 3 — Build the Activity section¶
The second tab iterates over the activity entries and creates a ListTile for each,
separated by Divider widgets.
def _activity_section(state: ProfileState) -> Widget:
"""Render the Activity tab: a card list of recent events.
Args:
state: The current application state.
Returns:
A ``Column`` of activity list tiles inside a ``Card``.
"""
tiles: list[Widget] = []
for i, entry in enumerate(state.activity):
tiles.append(
ListTile(
key=f"activity-{i}",
leading=Avatar(initials=entry.title[0], size=36.0, key=f"av-{i}"),
title=entry.title,
subtitle=entry.subtitle,
)
)
if i < len(state.activity) - 1:
tiles.append(Divider(key=f"div-{i}"))
return Column(
key="activity",
style=Style(gap=16.0, padding=Edge.all(16.0)),
children=[
Text(
content="Recent activity",
key="activity-heading",
style=Style(font_size=18.0, font_weight=FontWeight.BOLD),
),
Card(key="activity-card", children=tiles),
],
)
Tip
Every widget needs a unique key within the same parent. Here we use
f"activity-{i}" and f"div-{i}" to guarantee that in dynamic lists.
The reconciler uses these keys to apply minimal patches to the DOM.
Highlights:
ListTileacceptsleading(a widget on the left),titleandsubtitle.- The
if i < len(state.activity) - 1guard avoids a trailingDividerafter the last item.
Step 4 — Build the Settings section with Switch¶
The third tab uses Switch for boolean controls. Each Switch receives a ToggleEvent
handler.
def _settings_section(
state: ProfileState,
on_notifications: RouteChangeEvent | None,
toggle_notifications: ToggleEvent | None,
toggle_dark: ToggleEvent | None,
on_notifications_switch: object,
on_dark_switch: object,
) -> Widget:
"""Render the Settings tab: notification and theme toggles inside cards.
Args:
state: The current application state.
on_notifications: Unused placeholder kept for API symmetry.
toggle_notifications: Unused placeholder kept for API symmetry.
toggle_dark: Unused placeholder kept for API symmetry.
on_notifications_switch: Zero-argument-accepting handler for the
notifications ``Switch``.
on_dark_switch: Zero-argument-accepting handler for the dark-mode
``Switch``.
Returns:
A ``Column`` of settings cards.
"""
return Column(
key="settings",
style=Style(gap=16.0, padding=Edge.all(16.0)),
children=[
Text(
content="Settings",
key="settings-heading",
style=Style(font_size=18.0, font_weight=FontWeight.BOLD),
),
Card(
key="notif-card",
children=[
ListTile(
key="notif-tile",
title="Push notifications",
subtitle="Receive alerts for mentions and replies",
trailing=Switch(
checked=state.notifications_on,
on_change=on_notifications_switch, # type: ignore[arg-type]
key="notif-switch",
),
),
],
),
Card(
key="theme-card",
children=[
ListTile(
key="theme-tile",
title="Dark mode",
subtitle="Use a dark colour palette",
trailing=Switch(
checked=state.dark_mode,
on_change=on_dark_switch, # type: ignore[arg-type]
key="theme-switch",
),
),
],
),
Card(
key="account-card",
children=[
Text(
content="Account",
key="account-heading",
style=Style(font_size=16.0, font_weight=FontWeight.BOLD),
),
Divider(key="account-divider"),
ListTile(
key="email-tile",
leading=Avatar(initials="@", size=32.0, key="email-av"),
title="alex@example.com",
subtitle="Primary email address",
),
ListTile(
key="joined-tile",
leading=Avatar(initials="J", size=32.0, key="joined-av"),
title="Joined",
subtitle="March 2022",
),
],
),
],
)
Info
ListTile also accepts trailing — a widget placed on the right. Using a Switch
in that position is a classic settings-screen pattern.
Step 5 — The view function and event handlers¶
This is where everything connects. The view function defines event handlers as inner
functions and decides which section to render based on state.active_tab.
def view(app: App[ProfileState]) -> Widget:
"""Render the tabbed-profile UI from the current state.
The active section is determined by ``app.state.active_tab``. Tapping a
tab fires a :class:`~tempestweb._core.widgets.events.RouteChangeEvent`;
the handler reads ``event.params["index"]`` and writes it back via
:meth:`~tempestweb._core.core.state.App.set_state`.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The full widget tree for the current state.
"""
state: ProfileState = app.state
# --- Tab-change handler -------------------------------------------------
def on_tab_change(event: RouteChangeEvent) -> None:
"""Switch the active tab when the user taps a tab label.
Args:
event: The route-change event carrying the new tab index in
``params["index"]``.
"""
index: int = int(event.params.get("index", 0))
app.set_state(lambda s: setattr(s, "active_tab", index))
# --- Toggle handlers (Settings tab) -------------------------------------
def on_notifications_toggle(event: ToggleEvent) -> None:
"""Toggle push-notification preference.
Args:
event: The toggle event carrying the new ``checked`` state.
"""
app.set_state(lambda s: setattr(s, "notifications_on", event.checked))
def on_dark_toggle(event: ToggleEvent) -> None:
"""Toggle dark-mode preference.
Args:
event: The toggle event carrying the new ``checked`` state.
"""
app.set_state(lambda s: setattr(s, "dark_mode", event.checked))
# --- Build the active section -------------------------------------------
tab: int = state.active_tab
if tab == 0:
body: Widget = _overview_section(state)
elif tab == 1:
body = _activity_section(state)
else:
body = _settings_section(
state,
on_notifications=None,
toggle_notifications=None,
toggle_dark=None,
on_notifications_switch=on_notifications_toggle,
on_dark_switch=on_dark_toggle,
)
return Scaffold(
key="profile-scaffold",
app_bar=AppBar(title="Profile", key="profile-appbar"),
body=TabView(
key="profile-tabs",
tabs=_TABS,
active=state.active_tab,
on_change=on_tab_change,
child=body,
),
)
What is happening:
| Part | Responsibility |
|---|---|
on_tab_change |
Reads event.params["index"] and writes it to active_tab via set_state |
on_notifications_toggle |
Reads event.checked and flips notifications_on |
on_dark_toggle |
Reads event.checked and flips dark_mode |
if tab == 0 / 1 / else |
Selects which section builder to call |
TabView(active=..., child=body) |
Receives the active index and the already-built widget |
Warning
TabView does not manage state internally — it only renders the correct label
as active and fires on_change. It is your view's responsibility to persist the
index in state and pass the correct child.
Step 6 — Run the app¶
Run in Mode A (Python in the browser via Pyodide):
Run in Mode B (Python on the server via FastAPI + WebSocket):
Open http://localhost:8000 in your browser. You should see:
- ✅ AppBar with the title "Profile";
- ✅ Three tabs: Overview, Activity, Settings;
- ✅ Clicking each tab swaps the content without reloading the page;
- ✅ In the Settings tab, both
Switchcontrols respond to clicks and the summary in Overview reflects the new state.
Recap¶
In this tutorial you built a complete profile screen with three sections and learned:
- 💡
TabViewtakestabs(labels),active(index) andon_change(handler). You pass the already-builtchild—TabViewdoes not decide which section to render. - 💡
RouteChangeEventcarries the new index inevent.params["index"]. Useint(event.params.get("index", 0))to convert it safely. - 💡
ToggleEventcarries the new boolean inevent.checked— ideal forSwitchcontrols in settings screens. - 💡
Card+ListTile+Avatar+Dividerform a set of pre-built components for rich list layouts without any manual CSS. - 💡 The same
app.pyruns in both modes — WASM and Server — without any modification.
Next steps¶
- Read the central tutorial to understand the full tempestweb lifecycle.
- Add screen-to-screen navigation with the
router-pushexample (coming soon). - Explore tab transition animations with
AnimatedSwitcher.