Install
npx skillscat add giuseppescottolavina/agentui Install via the SkillsCat registry.
AgentUI — Build Skill Guide
Practical recipes for building web apps with AgentUI.
For framework overview and API concepts, see AGENTS.md.
For full API reference, see AGENTS_REFERENCE.md.
AI-friendly web components framework. 57 components, zero Shadow DOM.
📑 Index
| Section | Description |
|---|---|
| 🔥 Schema Form Showcase | Auto-generate entire forms from JSON Schema |
| 🤖 Ready-to-Use Agent Patterns | Form data, input values, theme control, discovery |
| 🗄️ State Management | Reactive store with persistence — createStore() |
| 📡 Event Bus | Component-to-component communication — bus |
| 🔗 Advanced Patterns | DataTable + Store, Form + Store, API Cache |
| 🏗️ PWA App Shell Components | Templates: dashboard, e-commerce, admin, full-bleed |
| 🚀 Modern App Shell Pattern | Lazy loading, performance, 100/100 Lighthouse |
| 📋 Common Patterns | Forms, modals, tabs, toast, drag & drop |
🔥 Schema Form Showcase
au-schema-formis AgentUI's killer feature for AI agents. Define a JSON Schema → get a complete, validated, accessible form with zero boilerplate.
How It Works
<au-schema-form id="my-form"></au-schema-form>
<script type="module">
const form = document.getElementById('my-form');
form.schema = {
title: "User Registration",
required: ["email", "password", "name"],
properties: {
name: { type: "string", title: "Full Name", minLength: 2, maxLength: 50 },
email: { type: "string", title: "Email", format: "email" },
password: { type: "string", title: "Password", minLength: 8, placeholder: "Min 8 characters" },
age: { type: "integer", title: "Age", minimum: 18, maximum: 120 },
bio: { type: "string", title: "Bio", multiline: true, maxLength: 500 },
role: { type: "string", title: "Role", enum: ["user", "admin", "editor"], enumLabels: ["User", "Administrator", "Editor"] },
newsletter: { type: "boolean", title: "Subscribe to newsletter" }
}
};
form.addEventListener('au-submit', (e) => {
console.log('Form data:', e.detail);
// { name: "John", email: "j@x.com", password: "...", age: 25, bio: "...", role: "admin", newsletter: true }
});
</script>What you get automatically:
- ✅
string→au-input(with type detection: email, url, password) - ✅
string+multiline: true→au-textarea - ✅
integer/number→au-input type="number"with min/max - ✅
boolean→au-switch - ✅
enum→au-dropdownwithau-options - ✅ Built-in validation:
required,minLength,maxLength,pattern,minimum,maximum,format(email/url) - ✅ Submit + Reset buttons with customizable labels
- ✅ Error messages displayed per-field
- ✅ XSS-safe (all schema values are escaped)
Attributes
| Attribute | Description | Default |
|---|---|---|
submit-label |
Submit button text | "Submit" |
reset-label |
Reset button text | "Reset" |
inline |
Horizontal layout | false |
readonly |
All fields read-only | false |
disabled |
All fields disabled | false |
API
form.schema = { ... }; // Set/update schema (triggers re-render)
form.getValues(); // Get current values as object
form.setValues({ name: "..." }); // Set values programmatically
form.validate(); // Returns boolean, shows errors
form.getErrors(); // Get error object { field: ["error"] }
form.reset(); // Reset to defaults
form.submit(); // Trigger submit programmaticallyExample: Settings Page
form.schema = {
title: "App Settings",
required: ["appName"],
properties: {
appName: { type: "string", title: "App Name", minLength: 1 },
darkMode: { type: "boolean", title: "Dark Mode" },
language: { type: "string", title: "Language", enum: ["en", "it", "es", "de"], enumLabels: ["English", "Italiano", "Español", "Deutsch"] },
maxItems: { type: "integer", title: "Max Items Per Page", minimum: 10, maximum: 100 },
apiUrl: { type: "string", title: "API Endpoint", format: "url", placeholder: "https://..." }
}
};Example: Contact Form with Pattern Validation
form.schema = {
required: ["name", "email", "message"],
properties: {
name: { type: "string", title: "Name", minLength: 2 },
email: { type: "string", title: "Email", format: "email" },
phone: { type: "string", title: "Phone", pattern: "^\\+?[0-9]{8,15}$", patternError: "Enter a valid phone number" },
subject: { type: "string", title: "Subject", enum: ["general", "support", "sales"], enumLabels: ["General Inquiry", "Technical Support", "Sales"] },
message: { type: "string", title: "Message", multiline: true, minLength: 10, maxLength: 1000 }
}
};🤖 Ready-to-Use Agent Patterns
Copy-paste these patterns directly. Tested and verified for AI agent workflows.
Form Data Collection
// Get all form values at once (PREFERRED)
const form = document.querySelector('au-form');
const data = form.getValues(); // { email: '...', password: '...' }
// Validate before submission
if (form.validate()) {
// All required fields are filled
console.log('Form data:', data);
}Manual Input Collection (without au-form)
// Collect all au-input values by name
const inputs = document.querySelectorAll('au-input[name]');
const data = {};
inputs.forEach(input => {
data[input.getAttribute('name')] = input.value;
});Setting Input Values Programmatically
// Set value - works like native input
const input = document.querySelector('au-input');
input.value = 'new value';
console.log(input.value); // 'new value'
// Trigger validation
input.setAttribute('value', 'another value');Component Discovery via describe()
// Get component metadata at runtime
const ButtonClass = customElements.get('au-button');
const schema = ButtonClass.describe();
// Returns: {
// name: 'au-button',
// description: 'Material Design 3 button',
// props: { variant: { type: 'string', values: [...] }, ... },
// events: ['click'],
// examples: ['<au-button variant="filled">Click</au-button>']
// }
// Supported on: au-button, au-input, au-card, au-checkbox, au-switch,
// au-dropdown, au-textarea, au-radio-group, au-alert, au-toast,
// au-modal, au-spinner, au-progressFind All Interactive Components
// Get all clickable/focusable AgentUI components
const interactive = document.querySelectorAll(
'au-button, au-input, au-checkbox, au-switch, au-chip, au-dropdown'
);
// Get component state
interactive.forEach(el => {
console.log(el.tagName, {
disabled: el.hasAttribute('disabled'),
value: el.value,
checked: el.checked
});
});Theme Control
// Toggle dark/light mode
import { Theme } from 'agentui-wc';
Theme.toggle();
// Set specific theme
Theme.set('dark');
Theme.set('light');
// Get current theme
const current = Theme.get(); // 'dark' | 'light'⏳ Component Readiness
Two-level readiness system for safe async component initialization.
Per-Component (AuElement)
Every component has .ready, .isReady, and emits au:ready:
const el = document.createElement('au-layout');
document.body.appendChild(el);
// Promise — resolves to element after first render
await el.ready; // → el
// Sync flag
el.isReady; // true
// Event (bubbles to document)
el.addEventListener('au:ready', () => { /* rendered */ });Framework-Level
Wait for ALL components to be registered:
import { whenReady } from 'agentui-wc';
await whenReady();
// All 50+ components are now defined in customElementsIIFE usage:
await AgentUI.whenReady();🗄️ State Management (createStore)
Built-in reactive store. Proxy-based, zero dependencies, optional localStorage persistence.
Basic Usage
import { createStore } from 'agentui-wc';
// Or via global: const store = AgentUI.createStore(...)
const store = createStore(
{ tasks: [], filter: 'all', count: 0 },
{ persist: 'my-app' } // optional: auto-save to localStorage
);
// Read state
console.log(store.state.tasks);
// Write state → subscribers notified automatically
store.state.count = 42;
// Subscribe to a specific key
const unsub = store.subscribe('count', (newVal, oldVal) => {
document.querySelector('#counter').textContent = newVal;
});
// Subscribe to ALL changes
store.subscribe('*', (key, newVal, oldVal) => {
console.log(`${key} changed: ${oldVal} → ${newVal}`);
});
// Cleanup
unsub();
store.destroy();API Reference
| Method | Signature | Description |
|---|---|---|
state |
store.state |
Reactive proxy — read/write properties directly |
subscribe |
subscribe(key, cb) → unsub() |
Watch a key or '*' for all. Returns unsubscribe fn |
batch |
batch(fn) |
Group changes — subscribers notified once at end |
getState |
getState() → Object |
Returns a plain copy (not the proxy) |
setState |
setState(partial) |
Merge partial state, notify affected subscribers |
destroy |
destroy() |
Clear all subscribers |
Persistence Pattern
// Auto-saves to localStorage under key "agentui:kanban"
const store = createStore(
{ columns: [], tasks: [] },
{ persist: 'kanban' }
);
// On page reload, state is restored automatically.
// Corrupt JSON is handled silently (falls back to initial state).Kanban Example (Store + Components)
const store = createStore(
{ tasks: [], filter: 'all' },
{ persist: 'kanban-app' }
);
// Render when tasks change
store.subscribe('tasks', (tasks) => {
const list = document.getElementById('task-list');
list.innerHTML = tasks
.filter(t => store.state.filter === 'all' || t.status === store.state.filter)
.map(t => `<au-card variant="outlined" data-id="${t.id}">
<h3>${t.title}</h3>
<au-chip>${t.status}</au-chip>
</au-card>`).join('');
});
// Add task
function addTask(title) {
store.state.tasks = [...store.state.tasks, {
id: Date.now(), title, status: 'todo'
}];
}
// Batch multiple changes (single re-render)
store.batch(() => {
store.state.filter = 'done';
store.state.tasks = store.state.tasks.map(t =>
t.id === 123 ? { ...t, status: 'done' } : t
);
});📡 Event Bus (bus)
Built-in event bus for component-to-component communication. LightBus — lightweight, zero dependencies.
Basic Usage
import { bus, UIEvents, showToast } from 'agentui-wc';
// Or via global: AgentUI.bus, AgentUI.showToast
// Subscribe
const unsub = bus.on('task:created', (data) => {
console.log('New task:', data.title);
});
// Emit
bus.emit('task:created', { title: 'Buy milk', id: 42 });
// One-time listener
bus.once('app:initialized', () => console.log('App ready'));
// Cleanup
unsub();Built-in UI Events
import { bus, UIEvents, showToast } from 'agentui-wc';
// Toast (preferred shorthand)
showToast('Saved!', { severity: 'success', duration: 3000 });
// Or via bus directly
bus.emit(UIEvents.TOAST_SHOW, { message: 'Error!', severity: 'error' });
// Listen for framework events
bus.on(UIEvents.THEME_CHANGE, (data) => console.log('Theme:', data));
bus.on(UIEvents.FORM_SUBMIT, (data) => console.log('Form:', data));
bus.on(UIEvents.MODAL_OPEN, () => console.log('Modal opened'));| Event Constant | Value | Fired When |
|---|---|---|
UIEvents.TOAST_SHOW |
ui:toast:show |
Toast requested |
UIEvents.TOAST_DISMISS |
ui:toast:dismiss |
Toast dismissed |
UIEvents.MODAL_OPEN |
ui:modal:open |
Modal opened |
UIEvents.MODAL_CLOSE |
ui:modal:close |
Modal closed |
UIEvents.THEME_CHANGE |
ui:theme:change |
Theme toggled |
UIEvents.FORM_SUBMIT |
ui:form:submit |
Form submitted |
| (bus event) | au:route-change |
Route changed (includes previous) |
When to Use Store vs Bus
| Use Case | Use |
|---|---|
| App state (tasks, user data, settings) | createStore() |
| UI notifications (toasts, modals) | bus / showToast() |
| Cross-component events ("task created") | bus.emit() / bus.on() |
| Persistent data (survives reload) | createStore({ persist: '...' }) |
🌐 HTTP Client (http)
Built-in fetch wrapper. Base URLs, auth headers, isolated instances, typed errors.
Basic CRUD
import { http } from 'agentui-wc/core/http';
const users = await http.get('/api/users');
await http.post('/api/users', { name: 'John', email: 'j@x.com' });
await http.put('/api/users/1', { name: 'Jane' });
await http.del('/api/users/1');Isolated API Client
const api = http.create({
baseURL: 'https://api.example.com/v2',
headers: { 'Authorization': 'Bearer token123' }
});
// All requests use the base URL and headers
const users = await api.get('/users'); // → https://api.example.com/v2/users
await api.post('/users', { name: 'New' }); // Same auth headerError Handling
import { http, HttpError } from 'agentui-wc/core/http';
try {
await http.get('/api/protected');
} catch (err) {
if (err instanceof HttpError) {
if (err.status === 401) showToast('Please login', { severity: 'error' });
if (err.status === 404) showToast('Not found', { severity: 'warning' });
}
}API Reference
| Method | Signature | Description |
|---|---|---|
get |
get(url) → Promise |
GET, auto-parses JSON |
post |
post(url, body) → Promise |
POST with JSON body |
put |
put(url, body) → Promise |
PUT with JSON body |
del |
del(url) → Promise |
DELETE |
request |
request(url, options) → Promise |
Custom request |
create |
create({ baseURL, headers }) → HttpClient |
Isolated instance |
⚡ Render Performance
DOM-aware utilities for smooth 60fps updates. rAF batching, memoization, debounce/throttle, fastdom, lazy rendering.
Batch DOM Updates
import { rafScheduler } from 'agentui-wc/core/render';
// Multiple schedule() calls in the same tick → single rAF frame
items.forEach(item => {
rafScheduler.schedule(() => {
item.el.style.transform = `translateX(${item.x}px)`;
});
});Memoize Expensive Computations
import { memo } from 'agentui-wc/core/render';
const computeLayout = memo((items) => {
return items.map(i => calculatePosition(i));
}, { maxSize: 50 }); // LRU: keeps last 50 resultsDebounce / Throttle
import { debounce, throttle } from 'agentui-wc/core/render';
// Search input — wait for 300ms of silence
const onSearch = debounce((query) => fetchResults(query), 300);
searchInput.addEventListener('au-input', (e) => onSearch(e.detail.value));
// Scroll handler — max 1 call per 16ms (60fps)
const onScroll = throttle(() => updateStickyHeader(), 16);
window.addEventListener('scroll', onScroll);Prevent Layout Thrashing
import { domBatch } from 'agentui-wc/core/render';
// ❌ Bad — causes reflow on every iteration
items.forEach(el => {
const h = el.offsetHeight; // READ → reflow
el.style.height = `${h * 2}px`; // WRITE → invalidate
});
// ✅ Good — reads first, then writes
items.forEach(el => {
domBatch.read(() => { el._h = el.offsetHeight; });
domBatch.write(() => { el.style.height = `${el._h * 2}px`; });
});Process Large Arrays Without Blocking
import { processInChunks } from 'agentui-wc/core/render';
// Process 10,000 rows, yielding every 100
await processInChunks(allRows, (row, index) => {
renderTableRow(row, index);
}, 100);
// The browser stays responsive between chunksLazy Render On Scroll
import { createVisibilityObserver } from 'agentui-wc/core/render';
const observer = createVisibilityObserver((el) => {
el.innerHTML = renderCard(el.dataset);
el.classList.add('loaded');
}, { rootMargin: '200px' }); // Pre-load 200px ahead
document.querySelectorAll('.placeholder').forEach(el => observer.observe(el));🗓️ Task Scheduling
Priority-based scheduling via
scheduler.postTask(). Automatic fallback tosetTimeout.
Priority Levels
import { scheduleTask } from 'agentui-wc/core/scheduler';
// Highest — user is waiting for this
await scheduleTask(() => validateForm(), 'user-blocking');
// Normal — visible updates
await scheduleTask(() => updateChart(), 'user-visible');
// Lowest — can wait
await scheduleTask(() => sendAnalytics(), 'background');Yield to Main Thread
import { yieldToMain } from 'agentui-wc/core/scheduler';
// Long loop — yield periodically to keep UI smooth
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 50 === 0) await yieldToMain();
}
}Process List with Auto-Yield
import { processWithYield } from 'agentui-wc/core/scheduler';
await processWithYield(thousandCards, (card) => {
renderCard(card);
}, 50); // Yield every 50 itemsBackground Tasks + Measure After Paint
import { runBackground, afterPaint } from 'agentui-wc/core/scheduler';
// Non-urgent work during idle time
runBackground(() => prefetchNextPage());
// Measure layout after browser has painted
await afterPaint();
const rect = element.getBoundingClientRect();🧭 SPA Router
Hash-based routing with
:paramsupport. Zero dependencies, chainable API.
Route Registration
import { Router } from 'agentui-wc/core/router';
Router
.on('/', () => renderHome())
.on('/about', () => renderAbout())
.on('/user/:id', ({ id }) => renderUser(id))
.on('/user/:id/post/:postId', ({ id, postId }) => renderPost(id, postId))
.notFound((path) => render404(path))
.start();Navigation
Router.navigate('/user/42'); // Navigate programmatically
console.log(Router.current); // '/user/42'Router + Store + Transitions
import { Router } from 'agentui-wc/core/router';
import { createStore } from 'agentui-wc';
import { transition } from 'agentui-wc/core/transitions';
const store = createStore({ currentPage: 'home' });
Router
.on('/', () => loadPage('home'))
.on('/settings', () => loadPage('settings'))
.start();
async function loadPage(name) {
store.state.currentPage = name;
const html = await fetch(`/content/${name}.html`).then(r => r.text());
await transition(() => {
document.getElementById('content').innerHTML = html;
});
}Cleanup
Router.stop(); // Remove listener, keep routes
Router.destroy(); // Full cleanup (routes + listener)🔗 Advanced Patterns (Store + Components)
createStore()shines when connecting app-level state to UI components.
DataTable + Store — Reactive Data Source
import { createStore } from 'agentui-wc';
const store = createStore(
{ users: [], filter: '', sortField: null },
{ persist: 'admin-panel' }
);
// Fetch → Store → Table (one-way data flow)
const table = document.querySelector('au-datatable');
store.subscribe('users', () => {
const filtered = store.state.users.filter(u =>
u.name.toLowerCase().includes(store.state.filter.toLowerCase())
);
table.setData(filtered);
});
store.subscribe('filter', () => {
// Re-trigger the users subscriber by reading the current value
store.state.users = [...store.state.users];
});
// Load data
const res = await fetch('/api/users');
store.state.users = await res.json(); // table auto-updates
// Search input
searchInput.addEventListener('au-change', (e) => {
store.state.filter = e.detail.value;
});Form + Store — Two-Way Data Binding
import { createStore, bus } from 'agentui-wc';
const store = createStore({ email: '', name: '', role: 'user' });
// Store → Form (populate fields on load)
const form = document.querySelector('au-form');
store.subscribe('*', (key, newVal) => {
const field = form.querySelector(`[name="${key}"]`);
if (field && field.value !== newVal) field.value = newVal;
});
// Form → Store (sync on submit)
form.addEventListener('au-submit', (e) => {
store.setState(e.detail.data);
bus.emit('user:saved', store.getState());
});
// Pre-populate from API
const user = await fetch('/api/me').then(r => r.json());
store.setState(user);API Cache + Store — Shared Fetch Cache
import { createStore } from 'agentui-wc';
const cache = createStore({}, { persist: 'api-cache' });
async function cachedFetch(url, maxAge = 60_000) {
const entry = cache.state[url];
if (entry && Date.now() - entry.ts < maxAge) return entry.data;
const data = await fetch(url).then(r => r.json());
cache.state[url] = { data, ts: Date.now() };
return data;
}
// Multiple components share the cache
const users = await cachedFetch('/api/users'); // fetches
const again = await cachedFetch('/api/users'); // cached!
// Invalidate
delete cache.state['/api/users'];🏗️ PWA App Shell Components (Built-In)
AgentUI includes a complete, responsive App Shell system. You don't need to build it — just compose these components.
AgentUI provides 5 components that together form a full PWA App Shell with responsive behavior baked in:
| Component | Role | Key Features |
|---|---|---|
au-layout |
Shell container — orchestrates header, drawer, content, footer, bottom nav. Use full-bleed for zero-padding layouts (kanban, maps). |
5 named slots: header, drawer, main (default), footer, bottom. Attr: full-bleed |
au-drawer |
Side navigation — responsive sidebar | mode: auto (recommended), permanent, temporary, rail. Supports expand-on-hover |
au-drawer-item |
Nav item inside drawer | icon, label, href, active, data-page |
au-navbar |
Top app bar | sticky, variant (surface, primary) |
au-bottom-nav |
Mobile bottom nav — auto-shows on compact screens | Hidden on desktop, visible on mobile |
How au-layout Slots Work
┌──────────────────────────────────────────────────────┐
│ slot="header" → au-navbar (sticky top bar) │
├──────────┬───────────────────────────────────────────┤
│ │ │
│ slot= │ default slot (main content area) │
│ "drawer" │ ← Your page content goes here │
│ │ │
│ au-drawer│ │
│ │ │
├──────────┴───────────────────────────────────────────┤
│ slot="footer" → Optional footer │
├──────────────────────────────────────────────────────┤
│ slot="bottom" → au-bottom-nav (mobile only) │
└──────────────────────────────────────────────────────┘au-drawer Responsive Modes (Automatic with mode="auto")
| Screen Size | Drawer Behavior | Bottom Nav |
|---|---|---|
| Desktop (≥ 840px) | Expanded sidebar, always visible | Hidden |
| Tablet (600-839px) | Rail mode (icons only), expand on hover | Hidden |
| Mobile (< 600px) | Hidden (opens as overlay on tap) | Visible |
This is 100% automatic with
mode="auto". No media queries, no JavaScript — the components handle all responsive transitions internally.
Template 1: Dashboard App Shell (Most Common)
<au-layout>
<!-- Top bar with branding and actions -->
<au-navbar slot="header" sticky>
<au-navbar-brand>My Dashboard</au-navbar-brand>
<au-navbar-actions>
<au-theme-toggle></au-theme-toggle>
<au-icon-button icon="notifications"></au-icon-button>
<au-avatar src="user.jpg"></au-avatar>
</au-navbar-actions>
</au-navbar>
<!-- Sidebar: auto-responsive (expanded → rail → overlay) -->
<au-drawer slot="drawer" mode="auto" expand-on-hover>
<au-drawer-item icon="dashboard" href="#dashboard" active>Dashboard</au-drawer-item>
<au-drawer-item icon="people" href="#users">Users</au-drawer-item>
<au-drawer-item icon="analytics" href="#analytics">Analytics</au-drawer-item>
<au-drawer-item icon="settings" href="#settings">Settings</au-drawer-item>
</au-drawer>
<!-- Main content: changes on navigation -->
<au-container>
<main id="content">
<!-- Page content loads here -->
</main>
</au-container>
<!-- Mobile bottom nav: auto-visible on compact screens only -->
<au-bottom-nav slot="bottom">
<au-bottom-nav-item icon="dashboard" label="Dashboard" active></au-bottom-nav-item>
<au-bottom-nav-item icon="people" label="Users"></au-bottom-nav-item>
<au-bottom-nav-item icon="analytics" label="Analytics"></au-bottom-nav-item>
<au-bottom-nav-item icon="settings" label="Settings"></au-bottom-nav-item>
</au-bottom-nav>
</au-layout>This gives you:
- ✅ Sticky header with branding and user actions
- ✅ Responsive sidebar (expanded → rail → overlay, zero config)
- ✅ Mobile bottom navigation (auto-shows < 600px)
- ✅ Scrollable content area (independent from header/drawer)
- ✅ Dark/light theme toggle
- ✅ All of this in ~20KB gzipped initial load
Template 2: E-Commerce Shell (No Drawer)
<au-layout>
<au-navbar slot="header" sticky>
<au-navbar-brand>ShopName</au-navbar-brand>
<au-navbar-actions>
<au-icon-button icon="search"></au-icon-button>
<au-icon-button icon="shopping_cart"></au-icon-button>
</au-navbar-actions>
</au-navbar>
<au-container>
<main id="content"><!-- Products grid --></main>
</au-container>
<au-bottom-nav slot="bottom">
<au-bottom-nav-item icon="home" label="Home" active></au-bottom-nav-item>
<au-bottom-nav-item icon="category" label="Categories"></au-bottom-nav-item>
<au-bottom-nav-item icon="shopping_cart" label="Cart"></au-bottom-nav-item>
<au-bottom-nav-item icon="person" label="Account"></au-bottom-nav-item>
</au-bottom-nav>
</au-layout>Template 3: Admin Panel (Permanent Drawer)
<au-layout>
<au-navbar slot="header" sticky variant="primary">
<au-navbar-brand>Admin Panel</au-navbar-brand>
</au-navbar>
<!-- Permanent drawer: always visible, never collapses -->
<au-drawer slot="drawer" mode="permanent">
<au-drawer-item icon="dashboard" href="#overview" active>Overview</au-drawer-item>
<au-drawer-item icon="group" href="#users">Users</au-drawer-item>
<au-drawer-item icon="assessment" href="#reports">Reports</au-drawer-item>
<au-drawer-item icon="admin_panel_settings" href="#config">Config</au-drawer-item>
</au-drawer>
<main id="content"></main>
</au-layout>💡 Key insight for agents:
au-layoutis NOT just a CSS grid wrapper — it's a responsive orchestrator. When you putau-drawerinslot="drawer"andau-bottom-navinslot="bottom", the layout automatically coordinates their visibility across breakpoints. You don't write any responsive CSS or JavaScript — the components talk to each other internally.
[!CAUTION]
NEVER overridepaddingon.au-layout-content— it silently defeats bottom-nav compensation.
For zero-padding layouts (kanban, maps, dashboards), use<au-layout full-bleed>.
The framework emits aconsole.warnat runtime if it detects the override.
Template 4: Full-Bleed Layout (Kanban, Maps, Dashboards)
<au-layout full-bleed>
<au-navbar slot="header" sticky>
<au-navbar-brand>Kanban Board</au-navbar-brand>
</au-navbar>
<!-- Content fills edge-to-edge, zero padding -->
<div id="kanban-board" style="display: flex; gap: 16px; height: 100%; padding: 16px;">
<!-- Your columns -->
</div>
<au-bottom-nav slot="bottom">
<au-bottom-nav-item icon="view_kanban" label="Board" active></au-bottom-nav-item>
<au-bottom-nav-item icon="list" label="List"></au-bottom-nav-item>
</au-bottom-nav>
</au-layout>🚀 Modern App Shell Pattern (RECOMMENDED)
For production apps, use this pattern instead of Minimal Page Setup.
This is howdemo/index.htmlachieves 100/100 Lighthouse with all 57 components.
Why App Shell?
| Approach | Initial Load | Lighthouse | DX for Agents |
|---|---|---|---|
| Minimal (all-in-one) | ~60KB + content | 70-85 | Simple but suboptimal |
| App Shell (lazy) | ~20KB shell → routes lazy | 100/100 | Optimal performance |
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ INITIAL LOAD (~20KB) │
│ index.html + agentui.css + shell-critical.js │
│ → Renders navbar, drawer, footer instantly │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ON NAVIGATION (lazy) │
│ dist/routes/{page}.js → Component bundle for that page │
│ content/{page}.html → HTML content fragment │
└─────────────────────────────────────────────────────────────┘Minimal App Shell Template (Copy This)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My AgentUI App</title>
<!-- Critical CSS: async load prevents render blocking -->
<link rel="preload" as="style" href="dist/agentui.css">
<link rel="stylesheet" href="dist/agentui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="dist/agentui.css"></noscript>
<!-- Modern 2026: Speculation Rules for prefetch -->
<script type="speculationrules">
{
"prefetch": [{ "urls": ["/dist/routes/home.js", "/dist/routes/dashboard.js"], "eagerness": "moderate" }]
}
</script>
<style>
/* Font + critical inline CSS */
@font-face { font-family: 'Roboto'; font-display: swap; src: url('https://fonts.gstatic.com/s/roboto/v47/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiA8.woff2') format('woff2'); }
body { font-family: var(--md-sys-typescale-font); background: var(--md-sys-color-background); margin: 0; }
:root { --md-sys-color-background: #FFFBFE; }
[data-theme="dark"] { --md-sys-color-background: #141218; }
/* Modern: Lazy render off-screen content */
au-example { content-visibility: auto; contain-intrinsic-size: auto 300px; }
</style>
</head>
<body>
<!-- App Shell: renders instantly -->
<au-layout>
<header slot="header">
<au-theme-toggle></au-theme-toggle>
</header>
<au-drawer slot="drawer" mode="auto">
<au-drawer-item icon="home" href="#home" data-page="home" active>Home</au-drawer-item>
<au-drawer-item icon="dashboard" href="#dashboard" data-page="dashboard">Dashboard</au-drawer-item>
</au-drawer>
<!-- Dynamic content area -->
<main id="content"></main>
</au-layout>
<script type="module">
// ====================================
// LAZY LOADING ENGINE (copy this!)
// ====================================
const loadedRoutes = new Set();
const contentArea = document.getElementById('content');
// Load route bundle on demand
async function loadRoute(name) {
if (loadedRoutes.has(name)) return;
loadedRoutes.add(name);
await import(`./dist/routes/${name}.js`);
}
// Load HTML content fragment
async function loadContent(pageId) {
const response = await fetch(`./content/${pageId}.html`);
return response.ok ? await response.text() : null;
}
// Navigate with View Transitions (smooth)
async function showPage(pageId) {
await loadRoute(pageId);
const content = await loadContent(pageId);
const updateDOM = () => {
contentArea.innerHTML = content || `<h1>${pageId}</h1>`;
// Execute inline scripts
contentArea.querySelectorAll('script').forEach(s => {
const newScript = document.createElement('script');
newScript.textContent = s.textContent;
s.replaceWith(newScript);
});
};
// Modern: View Transitions API for smooth navigation
if (document.startViewTransition) {
await document.startViewTransition(updateDOM).finished;
} else {
updateDOM();
}
}
// Prefetch on hover (anticipate navigation)
document.querySelectorAll('au-drawer-item').forEach(item => {
item.addEventListener('mouseenter', () => loadRoute(item.dataset.page));
item.addEventListener('click', e => {
e.preventDefault();
window.location.hash = item.dataset.page;
});
});
// Hash-based routing
window.addEventListener('hashchange', () => showPage(location.hash.slice(1) || 'home'));
showPage(location.hash.slice(1) || 'home');
</script>
</body>
</html>Performance Techniques Explained
| Technique | Code | Why It Matters |
|---|---|---|
| Async CSS | media="print" onload="this.media='all'" |
Prevents render-blocking |
| Speculation Rules | <script type="speculationrules"> |
Browser prefetches likely routes |
| content-visibility | content-visibility: auto |
Skips rendering off-screen content |
| View Transitions | document.startViewTransition() |
Smooth page transitions |
| Hover prefetch | mouseenter → loadRoute() |
Loads before user clicks |
| Route caching | loadedRoutes.has(name) |
Never re-download same route |
File Structure for App Shell
my-app/
├── index.html # App shell (copy template above)
├── content/ # HTML fragments (lazy loaded)
│ ├── home.html
│ ├── dashboard.html
│ └── settings.html
├── dist/
│ ├── agentui.css
│ ├── agentui.esm.js
│ └── routes/ # Route bundles (auto-generated by build)
│ ├── home.js
│ ├── dashboard.js
│ └── settings.js
└── app/
└── pages/ # Source pages (build input)
├── home.html
├── dashboard.html
└── settings.htmlBuild Command
bun run build # Generates dist/routes/*.js from app/pages/*.htmlCommon Patterns (Copy These)
<!-- Form with validation -->
<au-form>
<au-stack gap="md">
<au-input label="Email" type="email" required></au-input>
<au-input label="Password" type="password" required></au-input>
<au-button variant="filled">Submit</au-button>
</au-stack>
</au-form>
<!-- Card layout -->
<au-grid cols="3" gap="md">
<au-card variant="elevated">
<h3>Title</h3>
<p>Content</p>
<au-button variant="text">Action</au-button>
</au-card>
</au-grid>
<!-- Navigation tabs -->
<au-tabs active="0">
<au-tab>Tab 1</au-tab>
<au-tab>Tab 2</au-tab>
</au-tabs>
<!-- Modal dialog -->
<au-modal id="my-modal">
<h2>Modal Title</h2>
<p>Content</p>
<au-button onclick="this.closest('au-modal').close()">Close</au-button>
</au-modal>
<au-button onclick="document.getElementById('my-modal').open()">Open Modal</au-button>
<!-- Toast notification -->
<script type="module">
import { showToast } from './dist/agentui.esm.js';
showToast('Success!', { severity: 'success', duration: 3000 });
</script>
<!-- Drag & Drop (native HTML5, works with any component) -->
<au-card draggable="true" data-id="123"
ondragstart="event.dataTransfer.setData('text/plain', this.dataset.id)"
ondragend="this.style.opacity = '1'">
Drag me
</au-card>
<au-card ondragover="event.preventDefault(); this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="handleDrop(event, this)">
Drop here
</au-card>