SPA & View Transitions (lila-client.js)
Lila v2.0 brings an authentic Single Page Application (SPA) experience to server-rendered Python without requiring Node.js, Webpack, or complicated hydration setups. Powered by View Transitions API, intelligent hover prefetching, and reactive Tailwind CSS UI components.
How it Works
Page templates rendered with render() are wrapped in lila/base.jinja automatically (see Templates). The runtime automatically intercepts internal links, loads destination HTML in the background, smoothly cross-fades content inside <div id="lila-outlet" data-lila-outlet>, and executes scripts:
<!-- Only the outlet content transitions smoothly -->
<div id="lila-outlet" data-lila-outlet class="flex-1 flex flex-col">
{% block content %}{% endblock %}
</div>
<!-- Persistent elements keep state (audio players, sidebar menus, chats) -->
<aside data-persist="true" id="main-sidebar">
...
</aside>
<script src="{{ public('js/lila-client.js') }}"></script>
Navigation lifecycle
lila:before-swap— fired before the page content is replaced (page-scoped sockets/timers clean up here).- The
<title>, meta tags, canonical,<html lang>and every[data-lila-head]tag (styles/scripts/JSON-LD fromrender()) are synchronized. Identical tags are kept, so libraries are not downloaded twice. - Persisted elements (
data-persist="true", e.g. a sidebar) keep their DOM, but their links take theclass/aria-currentrendered for the new page, so the active item follows navigation. - Scripts run in document order: an external
<script src>finishes loading before the next inline script runs. lila:page-load/lila:navigate/lila:load— fired after the swap (useLila.onPageLoad(fn)).- Page scripts run on every visit: use
var/functionfor globals and call init code directly (DOMContentLoadedonly fires on the first full load). - Hover prefetch is de-duplicated and never stores redirected responses (e.g. an expired session redirecting to
/login). Same-page#hashlinks scroll natively.
lila-client.js is loaded in the <head> of the default layout, so inline page scripts can call lila(), Http(), lilaSSE() or lilaWS() on the first load too. Live updates without WebSockets: Server-Sent Events (data-sse).
HTML attributes reference
Everything below works without writing JavaScript, survives SPA navigations and is re-applied to content inserted with data-target or partials. Escape closes drawers and dropdowns.
| Group | Attribute | What it does |
|---|---|---|
| Navigation | a[href] | Internal links use the SPA router (View Transitions + hover prefetch). Opt out: data-no-router, data-no-prefetch. |
| Navigation | data-persist="true" | Element kept across navigations (players, sidebars); its links get the new page's active classes. |
| Navigation | data-partial-target="#sel" | Link loads only the [data-lila-outlet] fragment into #sel (+ data-transition, data-push-url). |
| Forms | form[data-lila-form] | Validate + send with Http() + feedback (see Forms & actions). |
| Forms | data-action="/api/x" | Button/link request: data-method, data-body, data-include, data-confirm, data-target, data-refresh, data-callback. |
| Forms | data-match="field" | Confirmation field must equal another field (data-match-message). |
| Forms | data-autosubmit[="ms"] | On a form or field: submit on change; text inputs wait ms after typing (default 400). |
| Forms | form[data-navigate] | GET form (filters/search) → SPA navigation to action?query. |
| Forms | data-char-count="#counter" | Writes "n / maxlength" into #counter (red at the limit). |
| Forms | data-password-toggle="#input" | Shows/hides a password (data-show-text / data-hide-text, aria-pressed). |
| Feedback | data-confirm="Text" | Native confirm() before a click/submit continues. |
| Feedback | data-copy="text | #sel" | Copies text or an element's value/text (fallback on http://); data-copy-message, data-copy-silent. |
| Overlays | data-drawer="#id" | Toggles a drawer (data-drawer-action="open|close"). Old data-drawer-target="id" still works. |
| Overlays | data-drawer-panel + data-orientation | Drawer panel: left | right | top | bottom (aliases up/down/start/end), data-size="20rem". |
| Overlays | data-drawer-close | Closes the drawer it is in (or the selector given). |
| Overlays | data-dropdown="#menu" | Toggles a menu; closes on outside click, Escape and navigation. Mark the menu data-dropdown-menu to keep it open on inner clicks. |
| Overlays | data-modal-target="#dialog" / data-close-modal | Opens (showModal) / closes a <dialog>. |
| UI | data-toggle-class="a b" | Toggles classes on data-toggle-target (default: the element itself). |
| UI | data-toggle-hidden="#sel" | Shows/hides elements (hidden attribute or .hidden), sets aria-expanded. |
| UI | data-remove[="#sel"] | Fades out and removes an element (default: closest [data-removable] or the parent). |
| UI | data-tab-target="panelId" | Tabs: .tabs container with .tab-trigger buttons and .tab-content panels. |
| UI | data-scroll[="#id"] | Smooth scroll to an element (or to the top). |
| UI | data-toggle="theme" | Light/dark switch, remembered in localStorage. |
| UI | data-filter="#list" | Filters the list's children (or [data-filter-item]) by text; [data-filter-empty="#list"] shows when nothing matches. |
| UI | <time data-local-time datetime="…"> | Formats dates in the visitor's locale: data-format="date|time|datetime|relative". |
| Components | data-carousel / data-carousel-slide | Carousel from plain HTML (see Carousel). |
| Components | {{ datatable(...) }} / {{ carousel(...) }} | Jinja helpers, auto-initialized. |
Drawers in any direction
<button data-drawer="#mobile-menu" aria-expanded="false">☰</button>
<aside id="mobile-menu" data-drawer-panel data-orientation="left" data-size="18rem"
class="bg-white dark:bg-slate-900 p-6">
<button data-drawer-close aria-label="Close">×</button>
<nav><a href="/">Home</a><a href="/docs">Docs</a></nav> <!-- links close it -->
</aside>
<!-- bottom sheet -->
<button data-drawer="#filters">Filters</button>
<section id="filters" data-drawer-panel data-orientation="bottom" class="bg-white p-6 rounded-t-2xl">...</section>
JS: Lila.drawer('#mobile-menu', 'open') or lila('drawer', { id: 'mobile-menu', action: 'close', orientation: 'top' }). Events: lila:drawer-open, lila:drawer-close. Focus moves into the drawer and back to the trigger.
Everyday helpers
<!-- dropdown -->
<div class="relative">
<button data-dropdown="#user-menu">Account ▾</button>
<ul id="user-menu" hidden class="absolute right-0 mt-2 card p-2">
<li><a href="/profile">Profile</a></li>
<li><a href="/logout" data-no-router>Log out</a></li>
</ul>
</div>
<!-- live search over a list + "no results" -->
<input type="search" data-filter="#products" placeholder="Search...">
<ul id="products"><li>Coffee</li><li>Tea</li></ul>
<p data-filter-empty="#products" hidden>No results</p>
<!-- filters that navigate (SPA) as you type/change -->
<form action="/products" method="get" data-navigate data-autosubmit="300">
<input type="search" name="q"><select name="sort"><option>new</option><option>price</option></select>
</form>
<!-- password + counter + copy -->
<input id="pwd" type="password" name="password">
<button type="button" data-password-toggle="#pwd" data-show-text="Show" data-hide-text="Hide">Show</button>
<textarea name="bio" maxlength="160" data-char-count="#bio-count"></textarea><small id="bio-count"></small>
<button data-copy="#api-key" data-copy-message="Copied!">Copy key</button>
<!-- dismissible alert, class toggle, smooth scroll, local dates -->
<div class="alert" data-removable>Saved! <button data-remove aria-label="Close">×</button></div>
<button data-toggle-class="hidden" data-toggle-target="#advanced">Advanced</button>
<button data-scroll>↑ Top</button>
<time data-local-time datetime="{{ order.created_at }}" data-format="relative"></time>
Forms & actions without JavaScript
Add data-lila-form to a form (or any of data-snackbar, data-toast,
data-target, data-redirect) and lila-client.js sends it with Http():
- Native HTML5 validation runs first (
required,type,min/max,minlength,pattern...) plusdata-match="other_field"for confirmations. - Method from
method(ordata-methodfor PUT/PATCH/DELETE), URL fromaction. Fields are sent as JSON (Object.fromEntries(new FormData(form)), repeated names become arrays); forms with files are sent asmultipart/form-data. The CSRF token is attached automatically. - Every submit button is disabled while the request runs; the clicked one shows a spinner (and
data-loading-text). Buttons are re-enabled when the answer arrives, on network errors and afterdata-timeoutms (default 20000, the request is aborted). - The answer is normalized:
success/okfalse or an HTTP error = error; message frommessage,msg,detailorerror. 422errors([{"field": "message"}]) are shown under each field (or in[data-error-for="field"]). - Feedback:
data-snackbar,data-toast="Success title"(+data-toast-error) and/or a[data-message]element inside the form that is shown with success/error styles. - On success:
data-redirect="/path"(or aredirectfield in the JSON, SPA navigation unlessdata-no-router;data-redirect-delayms),data-reset(whole form) ordata-reset="password,password_2",data-reload,data-target="#sel"+data-swap="inner|outer|append|prepend|text"with an HTML answer or{"html": "..."},data-refresh="#datatable-id"(re-fetches adatatable(url=...)). data-callback="fnName"callswindow.fnName(data, element, result)on success and error. Eventslila:request,lila:successandlila:errorbubble from the element.
<form action="/register" method="post" data-lila-form
data-toast="Account created" data-toast-error="Error" data-redirect="/dashboard">
<input name="name" required minlength="2">
<input name="email" type="email" required>
<input name="password" type="password" required minlength="8">
<input name="password_2" type="password" required
data-match="password" data-match-message="Passwords do not match">
<label><input type="checkbox" name="terms" value="1" data-unchecked="0"> Accept</label>
<div data-message hidden></div> <!-- optional inline message -->
<button type="submit" data-loading-text="Creating...">Create account</button>
</form>
Buttons and links use the same pipeline with data-action (links with data-action are never
handled by the SPA router). data-confirm asks first; data-body is JSON;
data-include="#form-or-container" merges its fields.
<button data-action="/api/items/3" data-method="DELETE" data-confirm="Delete item #3?"
data-snackbar data-refresh="#items-table">Delete</button>
<a href="#" data-action="/api/stats" data-method="GET" data-target="#stats">Refresh stats</a>
<div id="stats"></div>
<button data-action="/api/publish" data-body='{"id": 7}' data-callback="afterPublish">Publish</button>
<script>
function afterPublish(data, button, result) { if (result.ok) button.remove(); }
</script>
<!-- Show / hide an element (hidden attribute or .hidden class) -->
<button data-toggle-hidden="#danger-zone" aria-expanded="false">Delete account</button>
<div id="danger-zone" hidden>...</div>
From JavaScript: Lila.submit(form), Lila.action(button), Lila.refresh("#table").
Add data-native to a form to keep the browser's normal submit. The auth scaffold
(lila auth) uses only these attributes, without page scripts.
UI Components Reference
All components are styled natively with Tailwind CSS, with full dark-mode support:
1. Toast Notifications
lila('toast', {
title: 'Profile Updated',
description: 'Your changes have been saved successfully.',
type: 'success', // 'info' | 'success' | 'warning' | 'error'
position: 'bottom-right',
duration: 4000
});
2. Snackbar
lila('snackbar', {
message: 'Item removed from database',
type: 'info',
duration: 3000
});
3. ResponsiveDataTable
Renders a desktop table on large screens and converts automatically into mobile card views on small screens.
From Jinja, without JavaScript: {{ datatable(users, ["id", "name", ("email", "E-mail")], edit="openEditModal") }} (unique id, auto-initialized; see Templates). Tables release their resize listener when an SPA navigation removes them.
new ResponsiveDataTable('containerId', {
data: users,
columns: [
{ title: 'ID', key: 'id' },
{ title: 'Name', key: 'name' },
{ title: 'Email', key: 'email' }
],
rowsPerPage: 10,
search: true,
pagination: true,
edit: (e, item) => openEditModal(item),
delete: (e, item) => deleteRecord(item)
});
Carousel
Scroll-snap carousel styled with Tailwind: touch swipe, arrows, indicators, keyboard (← →), autoplay (paused on hover/focus/hidden tab), loop and prefers-reduced-motion. Initialized automatically on the first load and after every SPA navigation. Three ways to create one:
a) Plain HTML with data attributes (track, arrows and dots are generated)
<div data-carousel
data-carousel-autoplay="4000" <!-- ms, 0 = off (default) -->
data-carousel-loop="true" <!-- wrap around (default true) -->
data-carousel-arrows="true" <!-- prev/next buttons (default true) -->
data-carousel-indicators="true" <!-- dots (default true) -->
data-carousel-pause="true" <!-- pause on hover (default true) -->
class="h-72 rounded-2xl">
<div data-carousel-slide><img src="/img/1.webp" alt="" class="w-full h-full object-cover"></div>
<div data-carousel-slide><img src="/img/2.webp" alt="" class="w-full h-full object-cover"></div>
<!-- several per view: give the slide a basis class -->
<div data-carousel-slide class="basis-full md:basis-1/3 p-2"><div class="card h-full">Card</div></div>
</div>
Optional own controls inside the carousel: data-carousel-prev, data-carousel-next, data-carousel-indicators, data-carousel-track. (HTML comments inside the tag above are only explanatory.)
b) From Jinja
{{ carousel([{"image": "img/1.webp", "title": "Fast", "text": "...", "href": "/docs"}], autoplay=5000) }}
c) From JavaScript
// Markup added dynamically (or classes .carousel/.carousel-track/.carousel-item):
var sliders = lila('carousel', { selector: '.my-carousel', autoplay: 4000, loop: false }); // array
var slider = lila('carousel', { el: document.getElementById('hero') }); // one
slider.next(); slider.prev(); slider.go(2); slider.stop(); slider.start();
slider.index; slider.length;
document.getElementById('hero').addEventListener('lila:carousel-change', (e) => console.log(e.detail.index));
4. Command Palette (Ctrl+K)
// Press Ctrl+K anywhere or trigger manually:
lila('command', { action: 'toggle' });
5. Async HTTP Helper with CSRF
// Automatically extracts and attaches CSRF tokens and invalidates prefetch cache
const res = await Http('/api/users', 'POST', { name: 'Alex', email: 'alex@example.com' });
if (res.success) {
lila('toast', { title: 'Success', description: 'User created', type: 'success' });
}