Image Gallery with Lightbox 🚀¶
Build a virtualized 12-photo gallery where tapping any thumbnail opens a Dialog lightbox with the full-resolution image, caption, credit and Prev / Next / Close navigation — all in pure Python.
What you'll build¶
A dark-themed gallery featuring:
- 🖼 3-column grid rendered by
LazyGrid(automatic virtualization) - 👆 Each thumbnail is a
GestureDetectorthat opens the lightbox on tap - 💬
Dialoglightbox with full-res image, caption, author and 4 navigation buttons - ↔ Circular navigation (Prev / Next with wrap-around)
- 🔢
1 / 12counter centred between the buttons
Note — one state, two modes
The field selected: int | None is the only piece of state. None = gallery open; an integer = lightbox open. tempestweb runs this same code unchanged in Mode A (WASM/Pyodide) and Mode B (server + WebSocket).
Prerequisites¶
Make sure tempestweb is installed:
Recommended reading (optional but helpful):
- Basic tutorial — first steps with
App,view, andset_state - Managing state — how
set_stateworks - Execution modes — WASM vs. server
Creating the project¶
Step 1 — Modelling the data¶
Before any UI, think about the data. Each photo has four attributes:
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class GalleryImage:
"""A single gallery entry.
Attributes:
src: URL of the full-resolution image.
thumb: URL of the thumbnail image (lower resolution).
caption: Short descriptive caption shown in the lightbox.
author: Photographer / attribution credit.
"""
src: str
thumb: str
caption: str
author: str
Two separate URLs — thumb for the grid (400×300 px) and src for the lightbox (1200×800 px) — avoid downloading heavy images until the user actually clicks.
Tip — picsum.photos
The example uses https://picsum.photos/id/<N>/<width>/<height> to serve CC0 images without any API key. Any public URL works as a replacement.
Step 2 — Defining the state¶
@dataclass
class GalleryState:
"""Runtime state for the image gallery.
Attributes:
images: The full ordered list of gallery images.
selected: Index of the image currently open in the lightbox,
or ``None`` when the lightbox is closed.
"""
images: list[GalleryImage] = field(default_factory=lambda: list(_IMAGES))
selected: int | None = None
def make_state() -> GalleryState:
"""Build the initial gallery state.
Returns:
A fresh :class:`GalleryState` with all sample images and no selection.
"""
return GalleryState()
Note — int | None
selected is None while the lightbox is closed and an integer (the photo's index) when it is open. This is the "UI state as optional value" pattern — simple, without extra boolean flags.
Step 3 — Style constants¶
We centralise colours in named constants to avoid scattering raw numbers throughout the code:
from tempestweb._core.style import (
AlignItems,
Color,
Edge,
FontWeight,
JustifyContent,
Shadow,
TextAlign,
)
_WHITE: Color = Color(r=255, g=255, b=255)
_DARK_BG: Color = Color(r=18, g=18, b=18)
_OVERLAY_BG: Color = Color(r=0, g=0, b=0, a=0.85)
_CAPTION_BG: Color = Color(r=30, g=30, b=30)
_ACCENT: Color = Color(r=99, g=179, b=237)
_MUTED: Color = Color(r=160, g=160, b=160)
_CARD_BG: Color = Color(r=38, g=38, b=38)
_CARD_SHADOW: Shadow = Shadow(
color=Color(r=0, g=0, b=0, a=0.4),
blur=12.0,
offset_y=4.0,
)
Tip — Color with an alpha channel
Color(r=0, g=0, b=0, a=0.85) is black at 85% opacity — ideal for the semi-transparent lightbox overlay. The a field accepts a float between 0.0 (transparent) and 1.0 (fully opaque).
Step 4 — Thumbnail card¶
Each grid cell is a GestureDetector that sets state.selected = index on tap:
from tempestweb._core import App, Style, Widget
from tempestweb._core.widgets import (
Button,
Column,
Container,
Dialog,
GestureDetector,
Image,
ImageFit,
LazyGrid,
Row,
Text,
)
def _build_thumbnail_card(app: App[GalleryState], index: int) -> Widget:
"""Build a thumbnail card for one gallery image.
Creates a tappable card containing the thumbnail image and a short caption
overlay. Tapping it opens the lightbox by setting ``state.selected``.
Args:
app: The application handle.
index: The zero-based index of the image in ``state.images``.
Returns:
A :class:`GestureDetector` wrapping the thumbnail card.
"""
img: GalleryImage = app.state.images[index]
def open_lightbox() -> None:
"""Open the lightbox for this thumbnail."""
app.set_state(lambda s: setattr(s, "selected", index))
return GestureDetector(
key=f"thumb-{index}",
on_tap=open_lightbox,
child=Container(
style=Style(
radius=8.0,
shadow=_CARD_SHADOW,
background=_CARD_BG,
),
child=Column(
children=[
Image(
src=img.thumb,
fit=ImageFit.COVER,
alt=img.caption,
style=Style(
height=180.0,
radius=8.0,
),
),
Container(
style=Style(
padding=Edge.symmetric(vertical=6.0, horizontal=8.0),
),
child=Text(
content=img.caption,
style=Style(
font_size=12.0,
color=_MUTED,
max_lines=1,
),
),
),
],
),
),
)
Key points:
| Snippet | What it does |
|---|---|
key=f"thumb-{index}" |
Gives each card a stable identity for the reconciler |
ImageFit.COVER |
Crops the image to fill the space without distortion |
max_lines=1 |
Truncates long captions with an ellipsis |
open_lightbox (closure) |
Captures index as a parameter — each card remembers its own index |
Watch out — closures in loops
open_lightbox is created inside _build_thumbnail_card, which receives index as a parameter. That guarantees each closure captures the right value. If you were defining the handler directly inside a for i in range(N) loop, use def handler(i=i) to freeze the value.
Step 5 — Dialog lightbox¶
The lightbox is a Dialog with three sections: full-res image, caption block, and navigation strip:
def _build_lightbox(app: App[GalleryState], index: int) -> Widget:
"""Build the full-screen lightbox Dialog for the selected image.
Renders the full-resolution image with caption, author credit and
Previous / Next / Close navigation controls.
Args:
app: The application handle.
index: The zero-based index of the currently selected image.
Returns:
A :class:`Dialog` widget that floats above the gallery grid.
"""
images: list[GalleryImage] = app.state.images
img: GalleryImage = images[index]
total: int = len(images)
def close() -> None:
"""Close the lightbox."""
app.set_state(lambda s: setattr(s, "selected", None))
def go_prev() -> None:
"""Navigate to the previous image, wrapping around."""
app.set_state(lambda s: setattr(s, "selected", (index - 1) % total))
def go_next() -> None:
"""Navigate to the next image, wrapping around."""
app.set_state(lambda s: setattr(s, "selected", (index + 1) % total))
counter_text: str = f"{index + 1} / {total}"
return Dialog(
key="lightbox",
title=None,
on_dismiss=close,
children=[
Column(
style=Style(
gap=12.0,
padding=Edge.all(0.0),
background=_OVERLAY_BG,
radius=12.0,
min_width=320.0,
max_width=900.0,
),
children=[
# Full-resolution image.
Image(
src=img.src,
fit=ImageFit.CONTAIN,
alt=img.caption,
style=Style(
height=480.0,
radius=12.0,
background=_DARK_BG,
),
key="lightbox-img",
),
# Caption + author credit.
Container(
style=Style(
padding=Edge.symmetric(vertical=8.0, horizontal=16.0),
background=_CAPTION_BG,
),
child=Column(
style=Style(gap=4.0),
children=[
Text(
content=img.caption,
style=Style(
font_size=16.0,
font_weight=FontWeight.SEMIBOLD,
color=_WHITE,
text_align=TextAlign.CENTER,
),
key="lb-caption",
),
Text(
content=f"Photo by {img.author}",
style=Style(
font_size=12.0,
color=_MUTED,
text_align=TextAlign.CENTER,
),
key="lb-author",
),
],
),
),
# Navigation row: Prev · counter · Next · Close.
Row(
style=Style(
gap=8.0,
padding=Edge.symmetric(vertical=8.0, horizontal=16.0),
justify=JustifyContent.CENTER,
align=AlignItems.CENTER,
),
children=[
Button(
label="◀ Prev",
on_click=go_prev,
key="lb-prev",
style=Style(
background=_ACCENT,
color=_DARK_BG,
radius=6.0,
padding=Edge.symmetric(
vertical=8.0, horizontal=14.0
),
),
),
Text(
content=counter_text,
style=Style(
font_size=13.0,
color=_MUTED,
min_width=56.0,
text_align=TextAlign.CENTER,
),
key="lb-counter",
),
Button(
label="Next ▶",
on_click=go_next,
key="lb-next",
style=Style(
background=_ACCENT,
color=_DARK_BG,
radius=6.0,
padding=Edge.symmetric(
vertical=8.0, horizontal=14.0
),
),
),
Button(
label="✕ Close",
on_click=close,
key="lb-close",
style=Style(
background=Color(r=220, g=53, b=69),
color=_WHITE,
radius=6.0,
padding=Edge.symmetric(
vertical=8.0, horizontal=14.0
),
),
),
],
),
],
),
],
)
Three important details:
| Detail | Why |
|---|---|
(index - 1) % total |
Automatic wrap-around: Prev from photo 0 jumps to the last one |
ImageFit.CONTAIN |
Shows the whole image within the available space without cropping |
on_dismiss=close |
Clicking outside the Dialog (on the backdrop) also closes the lightbox |
Note — how the Dialog floats
tempestweb's renderer automatically promotes Dialog nodes to the overlay layer of the DOM — you don't need to manage z-index or portals manually. Include the Dialog as a regular child in the tree and the runtime handles the rest.
Step 6 — The view function and LazyGrid¶
def view(app: App[GalleryState]) -> Widget:
"""Render the gallery UI from the current state.
When ``state.selected`` is ``None`` the plain grid is rendered. When an
index is set a :class:`Dialog` lightbox is included in the widget tree as
a sibling at the column level; renderers promote ``Dialog`` nodes to the
overlay layer automatically.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current state.
"""
state: GalleryState = app.state
total: int = len(state.images)
def build_thumb(index: int) -> Widget:
"""Materialize one thumbnail card for the lazy grid.
Args:
index: The item's absolute position in the grid.
Returns:
The thumbnail card widget.
"""
return _build_thumbnail_card(app, index)
grid: Widget = LazyGrid(
key="gallery-grid",
item_count=total,
item_builder=build_thumb,
columns=3,
window_size=12,
style=Style(
gap=12.0,
padding=Edge.all(16.0),
background=_DARK_BG,
),
)
children: list[Widget] = [
Text(
content="Image Gallery",
style=Style(
font_size=24.0,
font_weight=FontWeight.BOLD,
color=_WHITE,
padding=Edge(top=20.0, left=20.0, bottom=4.0),
),
key="gallery-title",
),
Text(
content=f"{total} photos — tap any thumbnail to view full size",
style=Style(
font_size=13.0,
color=_MUTED,
padding=Edge(bottom=8.0, left=20.0),
),
key="gallery-subtitle",
),
grid,
]
# When a thumbnail is selected, append the lightbox Dialog to the tree.
# The renderer hoists Dialog nodes onto the overlay layer.
if state.selected is not None:
children.append(_build_lightbox(app, state.selected))
return Column(
key="gallery-root",
style=Style(
background=_DARK_BG,
gap=0.0,
),
children=children,
)
The key lies in the final lines of view:
When selected is None, children contains only the title, subtitle, and grid. When the user taps a thumbnail, selected becomes an integer and the Dialog is appended as a sibling — the renderer elevates it to the overlay.
Tip — LazyGrid and item_builder
LazyGrid accepts a callable item_builder(index: int) -> Widget. It does not materialise all children at once — only items within the window_size window are built. For 12 photos the gain is small, but the pattern scales to hundreds of items with no extra cost.
The complete app¶
Here is the full file, ready to copy:
"""Image gallery with lightbox — demonstrates LazyGrid, Image, and Dialog overlays.
A virtualized grid of photo thumbnails; tapping any thumbnail opens a full-screen
:class:`~tempestweb._core.widgets.Dialog` lightbox showing the selected image,
its caption and navigation controls (Previous / Next / Close). The selected index
lives in state, and ``None`` means the lightbox is closed.
Run unchanged in both modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
The application never names a transport — that is the whole point.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from tempestweb._core import App, Style, Widget
from tempestweb._core.style import (
AlignItems,
Color,
Edge,
FontWeight,
JustifyContent,
Shadow,
TextAlign,
)
from tempestweb._core.widgets import (
Button,
Column,
Container,
Dialog,
GestureDetector,
Image,
ImageFit,
LazyGrid,
Row,
Text,
)
@dataclass
class GalleryImage:
"""A single gallery entry.
Attributes:
src: URL of the full-resolution image.
thumb: URL of the thumbnail image (lower resolution).
caption: Short descriptive caption shown in the lightbox.
author: Photographer / attribution credit.
"""
src: str
thumb: str
caption: str
author: str
# ---------------------------------------------------------------------------
# Sample data — public domain / CC0 images via picsum.photos
# ---------------------------------------------------------------------------
_IMAGES: list[GalleryImage] = [
GalleryImage(
src="https://picsum.photos/id/10/1200/800",
thumb="https://picsum.photos/id/10/400/300",
caption="Mountain stream at dawn",
author="Unsplash / Lorenzo Spoleti",
),
GalleryImage(
src="https://picsum.photos/id/20/1200/800",
thumb="https://picsum.photos/id/20/400/300",
caption="City lights after rain",
author="Unsplash / Alejandro Escamilla",
),
GalleryImage(
src="https://picsum.photos/id/30/1200/800",
thumb="https://picsum.photos/id/30/400/300",
caption="Autumn forest trail",
author="Unsplash / Ales Krivec",
),
GalleryImage(
src="https://picsum.photos/id/40/1200/800",
thumb="https://picsum.photos/id/40/400/300",
caption="Desert sunrise",
author="Unsplash / Luca Bravo",
),
GalleryImage(
src="https://picsum.photos/id/50/1200/800",
thumb="https://picsum.photos/id/50/400/300",
caption="Ocean cliff at dusk",
author="Unsplash / Emile Perron",
),
GalleryImage(
src="https://picsum.photos/id/60/1200/800",
thumb="https://picsum.photos/id/60/400/300",
caption="Snow-capped peaks",
author="Unsplash / Luca Bravo",
),
GalleryImage(
src="https://picsum.photos/id/70/1200/800",
thumb="https://picsum.photos/id/70/400/300",
caption="Wheat field at noon",
author="Unsplash / Lukasz Lada",
),
GalleryImage(
src="https://picsum.photos/id/80/1200/800",
thumb="https://picsum.photos/id/80/400/300",
caption="Misty lake reflection",
author="Unsplash / Ales Krivec",
),
GalleryImage(
src="https://picsum.photos/id/90/1200/800",
thumb="https://picsum.photos/id/90/400/300",
caption="Redwood forest canopy",
author="Unsplash / Gian Luca Pilia",
),
GalleryImage(
src="https://picsum.photos/id/100/1200/800",
thumb="https://picsum.photos/id/100/400/300",
caption="Cobblestone alley, Porto",
author="Unsplash / Micah Hallahan",
),
GalleryImage(
src="https://picsum.photos/id/110/1200/800",
thumb="https://picsum.photos/id/110/400/300",
caption="Tuscan vineyards at harvest",
author="Unsplash / Roberta Sorge",
),
GalleryImage(
src="https://picsum.photos/id/120/1200/800",
thumb="https://picsum.photos/id/120/400/300",
caption="Neon night market",
author="Unsplash / Viktor Hanacek",
),
]
@dataclass
class GalleryState:
"""Runtime state for the image gallery.
Attributes:
images: The full ordered list of gallery images.
selected: Index of the image currently open in the lightbox,
or ``None`` when the lightbox is closed.
"""
images: list[GalleryImage] = field(default_factory=lambda: list(_IMAGES))
selected: int | None = None
def make_state() -> GalleryState:
"""Build the initial gallery state.
Returns:
A fresh :class:`GalleryState` with all sample images and no selection.
"""
return GalleryState()
# ---------------------------------------------------------------------------
# Style constants
# ---------------------------------------------------------------------------
_WHITE: Color = Color(r=255, g=255, b=255)
_DARK_BG: Color = Color(r=18, g=18, b=18)
_OVERLAY_BG: Color = Color(r=0, g=0, b=0, a=0.85)
_CAPTION_BG: Color = Color(r=30, g=30, b=30)
_ACCENT: Color = Color(r=99, g=179, b=237)
_MUTED: Color = Color(r=160, g=160, b=160)
_CARD_BG: Color = Color(r=38, g=38, b=38)
_HOVER_BORDER: Color = Color(r=99, g=179, b=237)
_CARD_SHADOW: Shadow = Shadow(
color=Color(r=0, g=0, b=0, a=0.4),
blur=12.0,
offset_y=4.0,
)
def _build_thumbnail_card(app: App[GalleryState], index: int) -> Widget:
"""Build a thumbnail card for one gallery image.
Creates a tappable card containing the thumbnail image and a short caption
overlay. Tapping it opens the lightbox by setting ``state.selected``.
Args:
app: The application handle.
index: The zero-based index of the image in ``state.images``.
Returns:
A :class:`GestureDetector` wrapping the thumbnail card.
"""
img: GalleryImage = app.state.images[index]
def open_lightbox() -> None:
"""Open the lightbox for this thumbnail."""
app.set_state(lambda s: setattr(s, "selected", index))
return GestureDetector(
key=f"thumb-{index}",
on_tap=open_lightbox,
child=Container(
style=Style(
radius=8.0,
shadow=_CARD_SHADOW,
background=_CARD_BG,
),
child=Column(
children=[
Image(
src=img.thumb,
fit=ImageFit.COVER,
alt=img.caption,
style=Style(
height=180.0,
radius=8.0,
),
),
Container(
style=Style(
padding=Edge.symmetric(vertical=6.0, horizontal=8.0),
),
child=Text(
content=img.caption,
style=Style(
font_size=12.0,
color=_MUTED,
max_lines=1,
),
),
),
],
),
),
)
def _build_lightbox(app: App[GalleryState], index: int) -> Widget:
"""Build the full-screen lightbox Dialog for the selected image.
Renders the full-resolution image with caption, author credit and
Previous / Next / Close navigation controls.
Args:
app: The application handle.
index: The zero-based index of the currently selected image.
Returns:
A :class:`Dialog` widget that floats above the gallery grid.
"""
images: list[GalleryImage] = app.state.images
img: GalleryImage = images[index]
total: int = len(images)
def close() -> None:
"""Close the lightbox."""
app.set_state(lambda s: setattr(s, "selected", None))
def go_prev() -> None:
"""Navigate to the previous image, wrapping around."""
app.set_state(lambda s: setattr(s, "selected", (index - 1) % total))
def go_next() -> None:
"""Navigate to the next image, wrapping around."""
app.set_state(lambda s: setattr(s, "selected", (index + 1) % total))
counter_text: str = f"{index + 1} / {total}"
return Dialog(
key="lightbox",
title=None,
on_dismiss=close,
children=[
Column(
style=Style(
gap=12.0,
padding=Edge.all(0.0),
background=_OVERLAY_BG,
radius=12.0,
min_width=320.0,
max_width=900.0,
),
children=[
# Full-resolution image.
Image(
src=img.src,
fit=ImageFit.CONTAIN,
alt=img.caption,
style=Style(
height=480.0,
radius=12.0,
background=_DARK_BG,
),
key="lightbox-img",
),
# Caption + author credit.
Container(
style=Style(
padding=Edge.symmetric(vertical=8.0, horizontal=16.0),
background=_CAPTION_BG,
),
child=Column(
style=Style(gap=4.0),
children=[
Text(
content=img.caption,
style=Style(
font_size=16.0,
font_weight=FontWeight.SEMIBOLD,
color=_WHITE,
text_align=TextAlign.CENTER,
),
key="lb-caption",
),
Text(
content=f"Photo by {img.author}",
style=Style(
font_size=12.0,
color=_MUTED,
text_align=TextAlign.CENTER,
),
key="lb-author",
),
],
),
),
# Navigation row: Prev · counter · Next · Close.
Row(
style=Style(
gap=8.0,
padding=Edge.symmetric(vertical=8.0, horizontal=16.0),
justify=JustifyContent.CENTER,
align=AlignItems.CENTER,
),
children=[
Button(
label="◀ Prev",
on_click=go_prev,
key="lb-prev",
style=Style(
background=_ACCENT,
color=_DARK_BG,
radius=6.0,
padding=Edge.symmetric(
vertical=8.0, horizontal=14.0
),
),
),
Text(
content=counter_text,
style=Style(
font_size=13.0,
color=_MUTED,
min_width=56.0,
text_align=TextAlign.CENTER,
),
key="lb-counter",
),
Button(
label="Next ▶",
on_click=go_next,
key="lb-next",
style=Style(
background=_ACCENT,
color=_DARK_BG,
radius=6.0,
padding=Edge.symmetric(
vertical=8.0, horizontal=14.0
),
),
),
Button(
label="✕ Close",
on_click=close,
key="lb-close",
style=Style(
background=Color(r=220, g=53, b=69),
color=_WHITE,
radius=6.0,
padding=Edge.symmetric(
vertical=8.0, horizontal=14.0
),
),
),
],
),
],
),
],
)
def view(app: App[GalleryState]) -> Widget:
"""Render the gallery UI from the current state.
When ``state.selected`` is ``None`` the plain grid is rendered. When an
index is set a :class:`Dialog` lightbox is included in the widget tree as
a sibling at the column level; renderers promote ``Dialog`` nodes to the
overlay layer automatically.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current state.
"""
state: GalleryState = app.state
total: int = len(state.images)
def build_thumb(index: int) -> Widget:
"""Materialize one thumbnail card for the lazy grid.
Args:
index: The item's absolute position in the grid.
Returns:
The thumbnail card widget.
"""
return _build_thumbnail_card(app, index)
grid: Widget = LazyGrid(
key="gallery-grid",
item_count=total,
item_builder=build_thumb,
columns=3,
window_size=12,
style=Style(
gap=12.0,
padding=Edge.all(16.0),
background=_DARK_BG,
),
)
children: list[Widget] = [
Text(
content="Image Gallery",
style=Style(
font_size=24.0,
font_weight=FontWeight.BOLD,
color=_WHITE,
padding=Edge(top=20.0, left=20.0, bottom=4.0),
),
key="gallery-title",
),
Text(
content=f"{total} photos — tap any thumbnail to view full size",
style=Style(
font_size=13.0,
color=_MUTED,
padding=Edge(bottom=8.0, left=20.0),
),
key="gallery-subtitle",
),
grid,
]
# When a thumbnail is selected, append the lightbox Dialog to the tree.
# The renderer hoists Dialog nodes onto the overlay layer.
if state.selected is not None:
children.append(_build_lightbox(app, state.selected))
return Column(
key="gallery-root",
style=Style(
background=_DARK_BG,
gap=0.0,
),
children=children,
)
Running the example ▶¶
Mode A — Python in the browser (Pyodide / WASM)¶
Python runs inside the browser via Pyodide. No server required.
Mode B — Python on the server (FastAPI + WebSocket)¶
Python runs on the server; the browser receives JSON patches over the WebSocket and applies them to the DOM.
Verification
In either mode, confirm:
- 3×4 photo grid on a dark background
- Each card shows a thumbnail with a truncated caption below
- Clicking any card opens the lightbox with the large image
- Lightbox shows caption, credit, and a
1 / 12counter - ◀ Prev and Next ▶ buttons navigate between photos (with wrap-around)
- ✕ Close button and clicking outside the Dialog close the lightbox
- After closing, the grid is back without a page reload
Automated verification ✅¶
Run the four checks before committing:
# Lint
ruff check .
# Formatting
ruff format --check .
# Types
mypy --strict tempestweb
# Tests
pytest -q
All must pass green. The example was designed to be mypy --strict clean — every variable, parameter, and return type is explicitly annotated.
How it works under the hood¶
The full cycle of opening a lightbox¶
User clicks thumbnail
│
▼
GestureDetector.on_tap → open_lightbox()
│
▼
app.set_state(lambda s: setattr(s, "selected", index))
│
▼
state.selected: None → 2 (example: third photo)
│
▼
view(app) called again
│
├─ Builds grid normally
└─ state.selected is not None → append(_build_lightbox(app, 2))
│
▼
Reconciler computes diff:
single new patch = INSERT Dialog
│
▼
Renderer promotes Dialog to overlay layer
Why None instead of False?¶
Using selected: int | None instead of is_open: bool + current_index: int has two advantages:
- A single field describes both possible states — gallery closed and photo selected.
- The
viewfunction can testif state.selected is not Noneand pass the index directly to_build_lightboxwithout needing a separatestate.current_index.
LazyGrid vs. list of widgets¶
| Approach | Cost on initial render | Cost while scrolling |
|---|---|---|
LazyGrid(item_builder=...) |
Builds only window_size items |
Builds on demand |
Column(children=[...]) |
Builds all items | None (already built) |
For 12 photos the difference is imperceptible. For 500+ items, LazyGrid keeps the UI responsive.
Recap¶
In this tutorial you learned:
- ✅ Model an overlay state with
int | None— one field for two states - ✅ Use
LazyGridwithitem_builderfor virtualised grids - ✅ Use
GestureDetectorto capture taps and open overlays - ✅ Use
Dialogas a native overlay — no manualz-index - ✅ Navigate circularly with modular arithmetic
(index ± 1) % total - ✅ Split builders into private functions (
_build_thumbnail_card,_build_lightbox) to keepviewreadable - ✅ Use
ImageFit.COVERfor thumbnails andImageFit.CONTAINin the lightbox
Next steps¶
Try extending the example:
- 💡 Add a search field that filters
state.imagesby caption - 💡 Implement swipe to dismiss with
GestureDetector.on_pan_end - 💡 Explore the Data Table example for another pattern of large lists with row selection
- 💡 Explore the Tabs Profile example for tab navigation inside overlays
- 💡 Return to the Basic tutorial to see how the reconciler computes the diffs that make all of this efficient