The Loading Pattern
loading.py is the
shipped route-scope loading UI for SPA navigation. When a subtree should
show something while the next route resolves, add this file — never a
hand-rolled navigation spinner.
Optional, and no loader is the common case.
Most subtrees ship without one and navigate with a plain fade. Add a
loading.py only when a section is actually
asked for a navigation loading state.
Route-to-route navigation only.
An in-page wait — an @rpc() call, a form
submit, a filter refetch, an upload — is ordinary
pp.state in the owning component and never
belongs here.
1. SPA navigation starts
Immediate2. Instant feedback
Fades in over 250ms3. Route content swaps in
index.py resolvesThe file
One file per subtree, exporting a function named
loading. It must be
synchronous and take
no parameters — there is no
request, no route params, no session,
and no page()-style data loading.
from casp.component_decorator import html def loading(): return html(r""" <div class="flex items-center gap-4" aria-busy="true"> <div class="h-3 w-64 rounded-full bg-muted animate-pulse"></div> <span class="text-sm text-muted-foreground">Loading…</span> </div> """)
An async def loading() — or any awaitable
return — raises TypeError. A
loading.py with no such function raises
AttributeError naming the file.
The pane it replaces
pp-loading-content="true"
goes on the layout element the loader
replaces — usually the content pane of the layout that owns the subtree.
It does not belong inside the loader. With no marker the runtime falls back to
document.body, which flashes the whole shell.
<main class="dashboard-content" pp-loading-content="true" pp-reset-scroll="true" > <slot /> </main>
The attribute is useful even without a loader: when no
loading.py scope matches, the runtime still
fades that same region out and back in.
Which element you mark changes the effect
The runtime does one thing: it replaces the marked element's
innerHTML with the loader's markup. So the
element you choose decides whether the loader
replaces the page or sits beside it.
| Mark this | What the reader sees |
|---|---|
| The content pane | The old page is wiped and the loader takes its place. Right when the loader is a skeleton of the page that is coming. |
| A small empty element |
The page stays on screen and the loader fills a strip you reserved for it
— a progress bar. Right when navigation is fast enough that blanking
the page reads as a flicker. This site does exactly that: an empty
absolute inset-x-0 bottom-0 div
inside the sticky header, so the bar rides the bottom edge of the top menu,
costs no layout space at rest, stays on screen at any scroll offset, and
carries pointer-events-none so it
never swallows a click.
|
Either way the marked element owns position only.
Every visible style — height, colour, animation — belongs in
loading.py, because its markup is what ends up
inside. Only the first
[pp-loading-content='true'] in the document
is used, so mark exactly one element.
Scope comes from the folder
Each loading.py registers a URL scope derived from
its folder. During navigation the runtime walks up
the destination pathname toward / until a scope
matches, so the closest ancestor loader wins.
| File | Scope | Applies to |
|---|---|---|
| src/app/loading.py | / | Every route with no closer loader |
| src/app/dashboard/loading.py | /dashboard | /dashboard and its descendants |
| src/app/(marketing)/loading.py | / | Every route in the app, not just the group. Group folders are stripped from the scope exactly as they are from the URL, so a loader placed directly inside a group collapses to the root scope. Put it in a real route folder under the group instead. |
| src/app/users/[id]/loading.py | /users/[id] |
Nothing. The lookup compares the
scope string against the real pathname, and no visited URL equals
/users/[id]. Put the loader on the static
parent, src/app/users/loading.py.
|
Two files can claim the same scope. The lookup takes the first match in document order, so give only one file a given scope rather than relying on that ordering.
The markup is static HTML
The loader is collected once into a hidden registry and injected with
innerHTML, never mounted. Jinja
{{ }} and
html(...) keyword context
do interpolate when loading() is
called — but nothing browser-side runs.
-
×
x-*tags stay literal and render nothing. -
×
PulsePoint
{ }bindings render as literal text. -
×
<script>,pp-for, andon*handlers do nothing. - ✓ Plain elements and CSS classes — skeletons, pulses, bars.
loading() is called once per file and cached
until the file's mtime changes, so its output is shared by every request
— never put per-user or per-request data in it. Every loader in the app
also ships inside every rendered page, so keep them small.
Tuning the fade
Put pp-loading-transition on an element
inside the loader markup to override
the 250 ms default in each direction.
<div pp-loading-transition='{"fadeIn": 120, "fadeOut": "1s"}'> <!-- skeleton --> </div>
Prefer skeletons to spinners
Mimic the layout of the destination page with plain boxes. It reduces the cognitive jump when the real content snaps into place — and plain boxes are exactly what this file can render.