This is the detailed API reference. For workflow and quick patterns, see AGENTS.md. For a concise overview, see llms.txt.
| Category | Components | Common Attributes |
|---|---|---|
| Layout | au-container, au-stack, au-grid, au-navbar, au-sidebar, au-divider |
gap="xs|sm|md|lg", direction="row|column" |
| Form | au-button, au-input, au-textarea, au-checkbox, au-switch, au-radio, au-radio-group, au-dropdown, au-option, au-chip, au-form |
variant, disabled, required, value |
| Display | au-card, au-tabs, au-tab, au-table, au-avatar, au-badge, au-callout, au-progress, au-skeleton, au-alert, au-icon |
variant="elevated|outlined|filled" |
| Feedback | au-modal, au-toast, au-toast-container, au-tooltip, au-spinner, au-confirm |
duration, position |
| Structural | au-if, au-show, au-repeat, au-portal, au-intersection, au-media, au-transition, au-timer |
condition, threshold, active |
| Performance | au-virtual-list, au-lazy |
items, renderItem |
| Data | au-fetch |
url, auto, interval |
| Enterprise | au-error-boundary |
fallback |
| Utility | au-theme-toggle |
- |
<!-- Sizes -->
size="xs" | size="sm" | size="md" | size="lg" | size="xl"
<!-- Gaps (for layout) -->
gap="xs" (4px) | gap="sm" (8px) | gap="md" (16px) | gap="lg" (24px) | gap="xl" (32px)
<!-- Button variants -->
variant="filled" | variant="elevated" | variant="tonal" | variant="outlined" | variant="text" | variant="danger" | variant="primary" | variant="secondary"
<!-- Card variants -->
variant="elevated" | variant="outlined" | variant="filled"
<!-- Alert severities -->
severity="info" | severity="success" | severity="warning" | severity="error"
<!-- Direction (stack) -->
direction="row" | direction="column"
<!-- Alignment -->
align="start" | align="center" | align="end" | align="stretch"
justify="start" | justify="center" | justify="end" | justify="space-between"<au-button variant="filled" size="md" disabled>Label</au-button>| Attribute | Values | Default |
|---|---|---|
variant |
filled, elevated, tonal, outlined, text, danger, primary, secondary, ghost |
primary |
size |
sm, md, lg |
md |
disabled |
boolean | false |
Events: click (native)
Icon Buttons (v0.1.28+):
<!-- Icon-only button -->
<au-button variant="ghost" size="sm">
<au-icon name="delete" size="18"></au-icon>
</au-button>
<!-- Icon + text button -->
<au-button variant="filled">
<au-icon name="save" size="18"></au-icon>
<span>Save</span>
</au-button>✅ Custom Children Preserved: Icon children are preserved during render. Add icons directly as children, not via attribute.
✅ Custom Classes Preserved: Agent-added classes like
my-custom-classare preserved when component updates.
<au-input label="Email" type="email" placeholder="Enter email" required></au-input>| Attribute | Values | Default |
|---|---|---|
type |
text, email, password, number, tel, url |
text |
label |
string | - |
placeholder |
string | - |
value |
string | "" |
disabled |
boolean | false |
required |
boolean | false |
variant |
outlined, filled |
outlined |
Events: au-input (on keystroke), au-change (on blur)
Accessibility: Uses for/id association, aria-label fallback
<au-switch label="Dark mode" checked></au-switch>| Attribute | Values | Default |
|---|---|---|
label |
string | - |
checked |
boolean | false |
disabled |
boolean | false |
Events: au-change with { checked: boolean, source: 'user' }
Accessibility: role="switch", aria-checked, keyboard (Space/Enter)
<au-radio-group name="size" value="md">
<au-radio value="sm">Small</au-radio>
<au-radio value="md">Medium</au-radio>
<au-radio value="lg">Large</au-radio>
</au-radio-group>| Attribute | Values | Default |
|---|---|---|
name |
string (group) | - |
value |
string | - |
disabled |
boolean | false |
Events: au-change with { value: string, source: 'user' } on group
Accessibility: role="radiogroup", role="radio", aria-checked
<au-dropdown placeholder="Select option" value="1">
<au-option value="1">Option 1</au-option>
<au-option value="2">Option 2</au-option>
</au-dropdown>| Attribute | Values | Default |
|---|---|---|
placeholder |
string | - |
value |
string | - |
disabled |
boolean | false |
Events: au-change with { value: string, label: string }
Accessibility: role="listbox", aria-expanded, full keyboard navigation (Arrows, Enter, Escape, Space)
<au-modal id="dialog">
<h2>Title</h2>
<p>Content</p>
</au-modal>Methods: .open(), .close()
Events: au-open, au-close
⚠️ LIFECYCLE:au-modalcaptures its innerHTML as a raw string duringconnectedCallback(), BEFORE child custom elements render. It then injects this string into a native<dialog>. Custom elements inside (au-input, au-dropdown, etc.) re-initialize correctly. However, event listeners attached directly to children are lost — use event delegation.
Pattern: Modal with Form
<au-modal id="task-modal" size="md">
<au-input id="task-title" label="Title"></au-input>
<au-dropdown id="task-priority" label="Priority">
<au-option value="low">Low</au-option>
<au-option value="medium" selected>Medium</au-option>
<au-option value="high">High</au-option>
</au-dropdown>
<au-button id="save-btn" variant="filled">Save</au-button>
<au-button id="cancel-btn" variant="text">Cancel</au-button>
</au-modal>const modal = document.getElementById('task-modal');
// Event delegation — events bubble through the dialog
modal.addEventListener('click', (e) => {
const btn = e.target.closest('au-button');
if (btn?.id === 'save-btn') {
const title = modal.querySelector('#task-title').value;
const priority = modal.querySelector('#task-priority').value;
console.log({ title, priority });
modal.close();
}
if (btn?.id === 'cancel-btn') modal.close();
});
// Listen for dropdown selection via bubbling custom event
modal.addEventListener('au-select', (e) => {
console.log('Selected:', e.detail.value, e.detail.label);
});
modal.open();<au-tabs active="0" id="my-tabs">
<au-tab>First</au-tab>
<au-tab>Second</au-tab>
</au-tabs>Events: au-change with { index: number }
<au-stack direction="row" gap="md" align="center" justify="space-between">
<div>Item 1</div>
<div>Item 2</div>
</au-stack><au-grid cols="3" gap="md">
<au-card>1</au-card>
<au-card>2</au-card>
<au-card>3</au-card>
</au-grid>Responsive Behavior: Grids with cols="3", cols="4", or cols="6" automatically collapse to 1 column on mobile (< 600px).
const list = document.querySelector('au-virtual-list');
list.items = largeArray;
list.itemHeight = 50;
list.renderItem = (item) => `<div class="item">${item.name}</div>`;AgentUI follows Material Design 3 Window Size Classes for adaptive layouts:
| Class | Width | Components Behavior |
|---|---|---|
| Compact | < 600px | au-grid → 1 column, au-bottom-nav visible, au-drawer → modal |
| Medium | 600-839px | au-drawer → rail mode |
| Expanded | ≥ 840px | au-drawer → expanded, au-bottom-nav hidden |
import { breakpoints } from 'agentui-wc/core/breakpoints';
// Reactive subscriptions (for components that need to react to changes)
const unsubscribe = breakpoints.subscribe((size) => {
console.log(`Now in ${size} mode`); // 'compact', 'medium', 'expanded'
});
// Direct checks (live queries - always current, no caching)
if (breakpoints.isCompact) {
// Mobile-specific logic
}
if (breakpoints.isMedium) {
// Tablet-specific logic
}
if (breakpoints.isExpanded) {
// Desktop-specific logic
}
// Cleanup when done
unsubscribe();- Don't hardcode media queries - Use the centralized
breakpointsutility - Use CSS for simple responsive -
au-gridautomatically collapses via CSS - Subscribe for complex logic - Components needing JS-driven adaptation should subscribe
- Clean up subscriptions - Always unsubscribe in
disconnectedCallback()
// Example: Component that adapts to breakpoints
class MyComponent extends AuElement {
#unsubscribe = null;
connectedCallback() {
super.connectedCallback();
this.#unsubscribe = breakpoints.subscribe(() => {
this.#updateLayout();
});
}
disconnectedCallback() {
this.#unsubscribe?.();
}
#updateLayout() {
if (breakpoints.isCompact) {
this.classList.add('mobile');
} else {
this.classList.remove('mobile');
}
}
}AgentUI's only communication layer. All inter-component messaging, state notifications, and framework events go through bus.
import { bus, UIEvents, showToast } from 'agentui-wc';// Subscribe — returns unsubscribe function
const unsub = bus.on('my:event', (data) => console.log(data));
unsub(); // Always clean up when done
// Subscribe once — auto-unsubscribes after first call
bus.once('app:ready', (data) => initializeApp(data));
// Emit (synchronous, ~100M ops/sec)
bus.emit('my:event', { key: 'value' });
// Emit async (returns Promise<{ delivered: true }>)
await bus.emitAsync('my:event', { key: 'value' });
// Check if anyone is listening
if (bus.hasListeners('my:event')) { /* ... */ }// Listen to ALL ui events
bus.on('ui:*', (data) => console.log('UI event:', data));
// Matches: ui:toast:show, ui:modal:open, ui:theme:change, etc.// Register a handler (service side)
const cleanup = bus.handle('user:getProfile', async (payload) => {
const user = await fetchUser(payload.id);
return { name: user.name, avatar: user.avatar };
});
// Call the handler (client side)
const profile = await bus.request('agentui', 'user:getProfile', { id: 42 });
console.log(profile.name); // "John"
// Remove handler
cleanup.unsubscribe();
// or: bus.unhandle('user:getProfile');Pre-defined event names used by framework components:
UIEvents.TOAST_SHOW // 'ui:toast:show'
UIEvents.TOAST_DISMISS // 'ui:toast:dismiss'
UIEvents.MODAL_OPEN // 'ui:modal:open'
UIEvents.MODAL_CLOSE // 'ui:modal:close'
UIEvents.THEME_CHANGE // 'ui:theme:change'
UIEvents.TAB_CHANGE // 'ui:tab:change'
UIEvents.DROPDOWN_SELECT // 'ui:dropdown:select'
UIEvents.FORM_SUBMIT // 'ui:form:submit'
UIEvents.FORM_VALIDATE // 'ui:form:validate'Usage:
// Listen for theme changes
bus.on(UIEvents.THEME_CHANGE, (data) => console.log('Theme:', data));
// Show toast via bus (same as showToast() helper)
bus.emit(UIEvents.TOAST_SHOW, { message: 'Saved!', severity: 'success' });showToast('Changes saved!', { severity: 'success', duration: 3000 });
showToast('Something went wrong', { severity: 'error' });
// severity: 'info' | 'success' | 'warning' | 'error'Always unsubscribe in disconnectedCallback to prevent memory leaks:
class MyComponent extends AuElement {
#unsubs = [];
connectedCallback() {
super.connectedCallback();
this.#unsubs.push(
bus.on('data:updated', (data) => this.render(data)),
bus.on('auth:change', (data) => this.updateAuth(data))
);
}
disconnectedCallback() {
super.disconnectedCallback();
this.#unsubs.forEach(fn => fn());
this.#unsubs = [];
}
}Use namespaced colon-separated names: domain:action
// ✅ Good — clear namespace and action
bus.emit('cart:item-added', { productId: 123 });
bus.emit('auth:login', { user });
bus.emit('settings:theme-changed', { theme: 'dark' });
// ❌ Bad — ambiguous, no namespace
bus.emit('update', data);
bus.emit('click', data);Centralized LIFO stack for handling ESC key in modal-like components. When multiple modals/dropdowns are open, ESC closes only the topmost one.
import { keyboard } from 'agentui-wc/core/keyboard';
class AuMyModal extends AuElement {
#unsubEsc = null;
open() {
this.hidden = false;
// Register ESC handler - returns unsubscribe function
this.#unsubEsc = keyboard.pushEscapeHandler(this, () => this.close());
}
close() {
this.hidden = true;
// Always unsubscribe when closing
this.#unsubEsc?.();
this.#unsubEsc = null;
}
disconnectedCallback() {
super.disconnectedCallback();
this.#unsubEsc?.(); // Cleanup on removal
}
}API:
| Method | Returns | Description |
|---|---|---|
pushEscapeHandler(element, callback) |
() => void |
Register handler, returns unsubscribe function |
isTopmost(element) |
boolean |
Check if element is top of stack |
stackDepth |
number |
Current stack depth (for debugging) |
Centralized z-index constants to avoid magic numbers. Auto-injected as CSS custom properties.
import { Z_INDEX } from 'agentui-wc/core/layers';
// Use in JavaScript
this.style.zIndex = Z_INDEX.modal;/* Use in CSS (auto-injected as --z-* tokens) */
.my-modal { z-index: var(--z-modal); }
.my-tooltip { z-index: var(--z-tooltip); }Layer Hierarchy (lowest to highest):
| Token | Value | Use For |
|---|---|---|
base |
1 | Default content |
sticky |
100 | Sticky headers, FABs |
dropdown |
1000 | Dropdowns, menus, popovers |
drawer |
1100 | Navigation drawers |
modal |
1200 | Modal dialogs |
toast |
1300 | Toast notifications |
tooltip |
1400 | Tooltips |
overlay |
9999 | Full-screen overlays |
devtools |
999999 | Agent dev tools (always on top) |
import { http, HttpError } from 'agentui-wc/core/http';
// Quick requests
const data = await http.get('/api/users');
await http.post('/api/users', { name: 'John' });
await http.put('/api/users/1', { name: 'Jane' });
await http.del('/api/users/1');
// Isolated client with base URL + auth headers
const api = http.create({
baseURL: 'https://api.example.com/v2',
headers: { 'Authorization': 'Bearer token123' }
});
const users = await api.get('/users');Error Handling:
try {
await http.get('/api/protected');
} catch (err) {
if (err instanceof HttpError) {
console.log(err.status, err.statusText, err.body);
}
}| 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 |
import { rafScheduler, memo, debounce, throttle, domBatch, createVisibilityObserver, processInChunks } from 'agentui-wc/core/render';// Batch DOM updates into single rAF frame
rafScheduler.schedule(() => el.style.transform = 'translateX(10px)');
// Memoize with LRU eviction
const cached = memo(expensiveFn, { maxSize: 100 });
// Debounce/throttle
const onResize = debounce(() => recalculate(), 200);
const onScroll = throttle(() => updatePosition(), 16);
// Prevent layout thrashing (fastdom pattern)
domBatch.read(() => { h = el.offsetHeight; });
domBatch.write(() => { el.style.height = `${h * 2}px`; });
// Lazy rendering via IntersectionObserver
const obs = createVisibilityObserver((el) => el.classList.add('visible'));
document.querySelectorAll('.card').forEach(el => obs.observe(el));
// Non-blocking processing of large arrays
await processInChunks(largeArray, (item) => renderRow(item), 100);| Export | Signature | Description |
|---|---|---|
rafScheduler |
.schedule(callback) |
Batch into single rAF |
memo |
memo(fn, { maxSize? }) |
Memoize with LRU |
debounce |
debounce(fn, delay=100) |
Delay until silence |
throttle |
throttle(fn, limit=100) |
Max one call per limit |
domBatch |
.read(fn) / .write(fn) |
Prevent layout thrashing |
createVisibilityObserver |
(onVisible, opts?) |
Lazy rendering |
processInChunks |
(items, fn, chunkSize=100) |
Yielding iteration |
import { scheduleTask, yieldToMain, processWithYield, runBackground, afterPaint } from 'agentui-wc/core/scheduler';
// Priority: 'user-blocking' | 'user-visible' | 'background'
await scheduleTask(() => validateForm(), 'user-blocking');
await scheduleTask(() => updateAnalytics(), 'background');
// Yield to let browser paint during long tasks
for (const item of items) {
processItem(item);
await yieldToMain();
}
// Process large lists with automatic yielding
await processWithYield(thousandItems, (item) => renderCard(item), 50);
// Non-urgent work (idle time)
runBackground(() => preCacheImages());
// Wait for next paint, then measure
await afterPaint();
const rect = element.getBoundingClientRect();| Function | Description |
|---|---|
scheduleTask(fn, priority?) |
Schedule with priority |
yieldToMain() |
Yield main thread to browser |
processWithYield(items, fn, chunk?) |
Chunked with yield |
runBackground(fn) |
Run during idle |
afterPaint() |
Wait for next paint |
import { Router } from 'agentui-wc/core/router';
Router
.on('/', () => renderHome())
.on('/about', () => renderAbout())
.on('/user/:id', ({ id }) => renderUser(id))
.notFound((path) => render404(path))
.start();
// Navigate programmatically
Router.navigate('/user/42');
console.log(Router.current); // '/user/42'
// Cleanup
Router.stop(); // Remove listener, keep routes
Router.destroy(); // Full cleanup| Method | Description |
|---|---|
on(path, handler) |
Register route (chainable). Supports :param |
notFound(handler) |
Set 404 handler |
navigate(path) |
Navigate to path |
current |
Current route path (getter) |
start() / stop() |
Start/stop listening |
destroy() |
Full cleanup |
import { html, safe, escapeHTML } from 'agentui-wc';
// Auto-escapes all interpolated values
const userInput = '<script>alert("xss")</script>';
element.innerHTML = html`<h2>${userInput}</h2>`;
// → <h2><script>alert("xss")</script></h2>
// Mark trusted HTML with safe()
const icon = '<au-icon name="home"></au-icon>';
element.innerHTML = html`<div>${safe(icon)}</div>`;
// Composable — nested templates compose safely
const items = ['One', 'Two', '<Three>'];
element.innerHTML = html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`;| Function | Description |
|---|---|
html`...${val}...` |
Tagged template — auto-escapes |
safe(str) |
Mark trusted HTML (no escaping) |
escapeHTML(str) |
Escape & < > " ' |
import { attachRipple, createRipple, RippleMixin } from 'agentui-wc/core/ripple';
// Attach to any element — auto-ripple on click/touch
const cleanup = attachRipple(myButton);
cleanup(); // Remove when done
// Manual ripple at coordinates
createRipple(element, mouseEvent);
// Component mixin — auto-attaches ripple lifecycle
class MyButton extends RippleMixin(AuElement) {
// Ripple added on connect, cleaned on disconnect
}import { getAuComponentTree, describe, enableVisualMarkers, getMCPActions, findByLabel } from 'agentui-wc/core/agent-api';
// DOM distillation — get simplified component tree
const tree = getAuComponentTree(document.body, {
visibleOnly: true,
interactiveOnly: true
});
// Returns: [{ tag, id, label, description, state, actions, rect, interactive, visible }]
// Natural language description
describe('#submit-btn'); // → 'Button "Submit". Click to activate.'
// Visual markers for screenshot-based AI
const markers = enableVisualMarkers({ markerStyle: 'badge' });
// Elements get labeled: [B1] Save, [B2] Cancel, [I1] Email
const el = getMarkerElement('B1'); // Resolve marker to element
// MCP-compatible action schema
const actions = getMCPActions();
// Returns schema for: click_button, fill_input, toggle_checkbox,
// select_option, open_modal, close_modal, select_tab, enable_visual_markers
// Fuzzy search by label
const matches = findByLabel('Submit');| Function | Description |
|---|---|
getAuComponentTree(root?, opts?) |
Distilled component tree |
describe(el|selector) |
Natural language description |
findByLabel(query, root?) |
Fuzzy label search |
getRegisteredComponents() |
Map of all au-* elements |
enableVisualMarkers(opts?) |
Add overlay labels |
disableVisualMarkers() |
Remove overlays |
getMarkerElement(id) |
Element by marker ID |
getMarkerMap() |
All marker mappings |
getMCPActions() |
MCP action schema |
import { auConfirm } from 'agentui-wc';
// Simple confirm - returns Promise<boolean>
const ok = await auConfirm('Delete this item?');
if (ok) deleteItem();
// With options
const confirmed = await auConfirm('Are you sure?', {
title: 'Confirm Delete',
confirmText: 'Delete',
cancelText: 'Cancel',
variant: 'danger' // 'primary' | 'danger' | 'warning'
});<!-- Declarative data fetching with automatic states -->
<au-fetch url="/api/users" id="users">
<template slot="loading"><au-skeleton lines="3"></au-skeleton></template>
<template slot="error"><au-alert severity="error">${error}</au-alert></template>
<template slot="empty"><p>No users found</p></template>
</au-fetch>
<script type="module">
const fetcher = document.getElementById('users');
// Set render function for array data
fetcher.renderItem = (user) => `
<au-card variant="outlined">
<strong>${user.name}</strong> - ${user.email}
</au-card>
`;
// Programmatic access
fetcher.data; // Current data
fetcher.state; // 'idle' | 'loading' | 'success' | 'error'
fetcher.refetch(); // Re-fetch data
// Events
fetcher.addEventListener('au-success', (e) => {
console.log('Data loaded:', e.detail.data);
});
</script>import { showToast } from 'agentui-wc';
showToast('Operation successful', {
severity: 'success', // 'info' | 'success' | 'warning' | 'error'
duration: 3000 // ms, 0 for persistent
});import { Theme } from 'agentui-wc';
Theme.init(); // Auto-detect or use saved
Theme.set('dark'); // 'light' | 'dark' | 'system'
Theme.toggle(); // Switch light/dark
const current = Theme.get();import { transition } from 'agentui-wc';
await transition(() => {
document.querySelector('#content').innerHTML = newHTML;
});AgentUI includes a Service Worker with intelligent caching. Control via console:
// Clear all caches and reload
await AgentUICache.clear();
// Get cache status
await AgentUICache.status();
// Returns: { version: "0.1.0", caches: { "agentui-precache-2.0.18": 5 } }
// Force check for updates
await AgentUICache.update();
// Prefetch URLs into cache
await AgentUICache.prefetch(['/assets/image.webp', '/data.json']);Complete guide for AI agents building Progressive Web Apps with AgentUI.
Copy this template to your app root as sw.js:
// sw.js - AgentUI Service Worker Template
const VERSION = '1.0.0'; // Bump on each deploy
const CACHE_PREFIX = 'myapp';
const PRECACHE_NAME = `${CACHE_PREFIX}-precache-${VERSION}`;
const RUNTIME_NAME = `${CACHE_PREFIX}-runtime-${VERSION}`;
// Critical assets (precached on install)
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/dist/agentui.css',
'/dist/routes/shell.js',
'/dist/routes/home.js',
'/favicon.png'
];
// INSTALL: Precache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(PRECACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting())
);
});
// ACTIVATE: Cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(names =>
Promise.all(names
.filter(n => n.startsWith(CACHE_PREFIX) && n !== PRECACHE_NAME && n !== RUNTIME_NAME)
.map(n => caches.delete(n))
)
).then(() => self.clients.claim())
);
});
// FETCH: Route requests
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// HTML: Network-first (always fresh)
if (request.destination === 'document') {
event.respondWith(networkFirst(request));
return;
}
// JS/CSS: Stale-while-revalidate (fast + fresh)
event.respondWith(staleWhileRevalidate(request));
});
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(PRECACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request) || caches.match('/');
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone());
return response;
}).catch(() => null);
return cached || fetchPromise || caches.match('/');
}Add to your index.html (after app shell loads):
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW failed:', err));
}
</script>| Asset Type | Strategy | Why |
|---|---|---|
| HTML | Network-first | Always get fresh content |
| JS/CSS/Images | Stale-while-revalidate | Fast load + background update |
| Fonts (woff2) | Cache-first | Immutable, never changes |
| API calls | Network-only | Data must be fresh |
Initial Load Flow:
─────────────────────────────────────────────────────────
Browser → SW Installed? ─No→ Network → Cache critical assets
│
Yes
│
▼
Cache → Return shell.js (instant)
│
▼
Render App Shell (navbar, theme)
│
▼
Lazy load route (home.js)
│
▼
Background: Check for SW update
[ ] manifest.json created (name, icons, theme_color)
[ ] sw.js in app root
[ ] PRECACHE_ASSETS includes shell.js + CSS
[ ] VERSION bumped on each deploy
[ ] Meta tags: theme-color, apple-mobile-web-app-capable
[ ] Favicon + PWA icons (192x192, 512x512)
[ ] HTTPS enabled (required for SW)
[ ] Tested offline mode
{
"name": "My AgentUI App",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"background_color": "#FFFBFE",
"theme_color": "#6750A4",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}# 1. Copy SW template
cp demo/sw.js my-app/sw.js
# 2. Update PRECACHE_ASSETS with your routes
# 3. Create manifest.json
# 4. Add meta tags to index.html
# 5. Register SW in index.html
# 6. Build and test
bun run buildAll form components include proper ARIA attributes:
| Component | Role | Key Attributes |
|---|---|---|
au-checkbox |
checkbox |
aria-checked="true|false|mixed", tabindex |
au-switch |
switch |
aria-checked, aria-label |
au-radio |
radio |
aria-checked, tabindex |
au-radio-group |
radiogroup |
- |
au-dropdown |
trigger + listbox | aria-haspopup, aria-expanded |
au-button |
button |
tabindex |
au-input |
- | for/id label association, aria-label fallback |
Keyboard navigation: All interactive components support Tab, Space, Enter.
Practical examples based on real agent feedback. These patterns solve the most common issues agents encounter.
<!-- ✅ CORRECT: Update button text directly -->
<au-button id="submit-btn">Submit</au-button>
<script>
document.getElementById('submit-btn').textContent = 'Processing...';
</script>
<!-- ❌ AVOID: Nested elements for dynamic content -->
<au-button><span id="label">Submit</span></au-button>Why? AgentUI components use textContent directly. Nested elements break internal structure.
<!-- ✅ Modern: Declarative i18n with data attribute -->
<au-button data-i18n="common.submit">Submit</au-button>
<au-input label="Email" data-i18n-label="form.email"></au-input>
<script>
function updateTranslations(t) {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-label]').forEach(el => {
el.setAttribute('label', t(el.dataset.i18nLabel));
});
}
</script><au-input id="email" label="Email" type="email"></au-input>
<au-button id="submit">Submit</au-button>
<script>
const emailInput = document.getElementById('email');
const submitBtn = document.getElementById('submit');
// Listen for value changes
emailInput.addEventListener('au-change', (e) => {
console.log('Value:', e.detail.value);
});
// Get current value
const value = emailInput.value;
// Form validation
emailInput.addEventListener('au-validate', (e) => {
if (!e.detail.valid) {
console.log('Error:', e.detail.message);
}
});
</script>import { bus, UIEvents, showToast } from 'agentui-wc';
// Show toast notification
showToast('Changes saved!', { severity: 'success', duration: 3000 });
// Alternative: Direct bus emit
bus.emit(UIEvents.TOAST_SHOW, {
message: 'Error occurred',
severity: 'error'
});
// Open modal programmatically
document.querySelector('au-modal').open();
// Close modal
document.querySelector('au-modal').close();
// Listen for modal close
document.querySelector('au-modal').addEventListener('au-close', () => {
console.log('Modal closed');
});// Check for component errors (agent debugging)
const errors = window.AgentUIAgent.getErrors();
console.log('Errors:', errors);
// Check specific component
if (window.AgentUIAgent.hasError('au-input', 'A11Y_MISSING_LABEL')) {
console.log('Input missing label!');
}
// Reset errors
window.AgentUIAgent.reset();
// Listen for validation errors
document.querySelector('au-input').addEventListener('au-error', (e) => {
console.log('Validation error:', e.detail.message);
});// Query component capabilities at runtime
const buttonInfo = customElements.get('au-button')?.describe?.();
console.log(buttonInfo);
// { name: 'au-button', props: [...], events: [...], examples: [...] }
// Check if component is registered
if (customElements.get('au-modal')) {
// Safe to use au-modal
}- Use semantic components -
<au-input label="Email">not<input placeholder="Email"> - Use layout components -
<au-stack gap="md">not manual CSS flexbox - Use declarative HTML - Attributes over JavaScript for configuration
- Always include labels - For accessibility compliance
- Use
variantattributes - For consistent MD3 styling - Use
au-virtual-list- For lists with 100+ items - Use
au-lazy- For below-the-fold heavy components
- Don't use raw CSS for layout - Use
au-stack,au-grid,au-container - Don't forget
variant- Buttons/cards need explicit variant - Don't manipulate Shadow DOM - Components manage their own internals
- Don't use inline styles for spacing - Use
gapattributes - Don't create custom form controls - Use the provided accessible ones
Layout components (au-stack, au-grid) have CSS fallback styles that apply BEFORE JavaScript loads. This prevents Flash of Unstyled Content (FOUC).
/* CSS attribute selectors work immediately */
au-stack { display: flex; flex-direction: column; gap: 16px; }
au-stack[gap="lg"] { gap: 24px; }
au-grid { display: grid; gap: 16px; }
au-grid[cols="3"] { grid-template-columns: repeat(3, 1fr); }Why this matters: HTML is parsed before JavaScript executes. Without CSS fallback, layout elements have no styling until custom element upgrade completes.
<!-- WRONG: Missing variant -->
<au-button>Click</au-button>
<!-- CORRECT: Explicit variant -->
<au-button variant="filled">Click</au-button>
<!-- WRONG: Raw div layout -->
<div style="display: flex; gap: 16px;">
<!-- CORRECT: Use au-stack -->
<au-stack direction="row" gap="md">
<!-- WRONG: Placeholder only (accessibility issue) -->
<au-input placeholder="Email"></au-input>
<!-- CORRECT: Use label -->
<au-input label="Email"></au-input>AgentUI provides centralized utilities for safe HTML rendering. Always use the html tagged template for dynamic content — it auto-escapes all interpolated values.
// Import the html tagged template (RECOMMENDED)
import { html, safe } from 'agentui-wc';
// ✅ SAFE: html`` auto-escapes all interpolated values
const userName = '<script>alert("xss")</script>';
element.innerHTML = html`<span>${userName}</span>`;
// Result: <span><script>alert("xss")</script></span>
// ✅ SAFE: Use safe() ONLY for trusted HTML you control
const icon = '<au-icon name="home"></au-icon>';
element.innerHTML = html`<div>${safe(icon)}</div>`;
// Result: <div><au-icon name="home"></au-icon></div>
// ✅ SAFE: Nested templates compose correctly
const items = ['One', 'Two', '<script>evil</script>'];
element.innerHTML = html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`;
// Result: <ul><li>One</li><li>Two</li><li><script>evil</script></li></ul>
// ✅ SAFE: Use textContent for plain text
element.textContent = userName;
// ❌ UNSAFE: Never use innerHTML with raw template literals
element.innerHTML = `<span>${userName}</span>`; // XSS RISK!| Pattern | When to Use |
|---|---|
html`...${val}...` |
Recommended. Dynamic values in innerHTML — auto-escaped |
safe(trustedHTML) |
Opt-out for HTML you trust (e.g., component output) |
escapeHTML(str) |
Lower-level utility for manual escaping |
textContent |
Displaying plain text (no HTML needed) |
createElement |
Creating elements with user-provided attributes |
Components using these patterns: au-datatable, au-api-table, au-avatar, au-fetch
dist/
├── agentui.esm.js # Full ESM bundle (177 KB)
├── agentui.min.js # Full IIFE bundle (178 KB)
├── agentui.css # Combined CSS (94 KB)
├── agentui.d.ts # TypeScript definitions
├── routes/ # Route Bundles (Modern)
│ ├── shell.js # App Shell (Navbar, Theme, Bus) - 13 KB raw / 5 KB gzip
│ ├── home.js # Home route components
│ ├── chunk-*.js # Shared dependencies (deduplicated)
│ ├── route-deps.json # Auto-generated page dependencies
│ └── page-components.json # Debug: components per page
└── AGENTS.md # This file
For Modern performance, use the App Shell pattern. Load the critical shell first, then lazily load routes.
<head>
<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>
</head>
<body>
<!-- Global Shell Components -->
<au-navbar>...</au-navbar>
<au-toast-container></au-toast-container>
<!-- App Router -->
<au-router base="/app-dist/pages" default="home"></au-router>
<!-- Loader Script -->
<script type="module">
// 1. Load Shell (Navbar, Theme, EventBus, Toasts)
await import('./dist/routes/shell.js');
// 2. Initialize Router (which loads pages + their bundles)
const router = document.querySelector('au-router');
// Helper to manually load a route bundle if needed
window.loadRoute = async (name) => {
await import(`./dist/routes/${name}.js`);
};
</script>
</body>| Bundle | Content | Size (gzip) |
|---|---|---|
shell.js |
Navbar, Theme, EventBus, Toasts | ~5 KB gzip |
home.js |
Landing page components | ~2 KB |
forms.js |
(Example) Input, Button, Checkbox | ~7 KB |
Why this is Modern:
- The user only downloads
shell.js(small) to see the UI. - Page bundles (
home.js) are only downloaded when navigating to that page. - Common code (EventBus, BaseClass) is shared via
chunk-*.jsfiles automatically.
- Bundle size: 177 KB full / ~20 KB initial shell (gzipped: 60 KB total / ~20 KB initial)
- Lighthouse: 100% Performance, 100% Accessibility, 100% SEO, 100% Best Practices
- EventBus: ~100M ops/sec (LightBus emitSync)
- Virtual List: Handles 10K+ items at 60fps
- First paint: <100ms (precached assets)
Critical patterns for 100% Lighthouse scores and excellent UX.
Use au-skeleton to show content placeholders while loading:
<!-- Before data loads -->
<au-card>
<au-skeleton variant="text" width="60%"></au-skeleton>
<au-skeleton variant="text" lines="3"></au-skeleton>
<au-skeleton variant="rectangular"></au-skeleton>
</au-card>
<!-- Replace with real content when ready -->
<au-card>
<h3>User Profile</h3>
<p>Actual content...</p>
<au-button>Edit</au-button>
</au-card>| Attribute | Values | Default |
|---|---|---|
variant |
text, circular, rectangular |
text |
width |
CSS width | 100% |
lines |
Number of text lines | 1 |
animated |
boolean | true |
Use au-lazy to defer loading of non-critical content:
<!-- Content loads only when scrolled into view -->
<au-lazy threshold="200px">
<au-card>
<img src="heavy-image.webp">
<p>This loads lazily</p>
</au-card>
</au-lazy>
<!-- With loading placeholder -->
<au-lazy placeholder="<au-skeleton variant='rectangular'></au-skeleton>">
<img src="hero.webp" alt="Hero">
</au-lazy>| Attribute | Description | Default |
|---|---|---|
threshold |
Distance from viewport to trigger load | 100px |
placeholder |
HTML to show while loading | (empty) |
Add to <head> for faster loading:
<head>
<!-- Preconnect to external origins (fonts, CDN) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Preload critical assets (above-the-fold) -->
<link rel="preload" href="/dist/agentui.css" as="style">
<link rel="preload" href="/dist/routes/shell.js" as="script" crossorigin>
<link rel="preload" href="/assets/logo.webp" as="image" type="image/webp">
<!-- Prefetch likely next pages (after current page loads) -->
<link rel="prefetch" href="/dist/routes/home.js">
</head>| Hint | When to Use | Priority |
|---|---|---|
preconnect |
External domains you'll fetch from | 🔴 High |
preload |
Critical above-the-fold assets | 🔴 High |
prefetch |
Next likely navigation | 🟢 Low |
dns-prefetch |
External domains (fallback) | 🟢 Low |
| Metric | Target | How to Achieve |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Preload hero image, inline critical CSS |
| FID (First Input Delay) | < 100ms | Code split, defer non-critical JS |
| CLS (Cumulative Layout Shift) | < 0.1 | Set dimensions on images, reserve space |
<!-- LCP: Preload hero image with fetchpriority -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- CLS: Always set dimensions -->
<img src="photo.webp" width="800" height="600" alt="...">
<!-- CLS: Reserve space for dynamic content -->
<div style="min-height: 300px;">
<au-lazy><au-card>...</au-card></au-lazy>
</div>Use the View Transitions API for cinematic page changes:
import { transition, transitionNamed } from 'agentui-wc';
// Basic transition
await transition(() => {
document.getElementById('content').innerHTML = newHTML;
});
// Named transition with custom animation
await transitionNamed(() => {
router.navigate('/users');
}, { name: 'page-slide' });/* Custom transition animation */
::view-transition-old(page-slide) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(page-slide) {
animation: slide-in 0.3s ease-out;
}For lists with 100+ items, use au-virtual-list:
const list = document.querySelector('au-virtual-list');
list.items = largeArray; // 10K+ items OK
list.itemHeight = 48; // Fixed height for calculation
list.renderItem = (item, index) => `
<div class="list-item">
<span>${item.name}</span>
<span>${item.email}</span>
</div>
`;Preload routes on hover for instant navigation:
// Add to navigation links
document.querySelectorAll('a[data-route]').forEach(link => {
link.addEventListener('mouseenter', () => {
const route = link.dataset.route;
// Prefetch route bundle
import(`/dist/routes/${route}.js`);
});
});AgentUI CSS includes :not(:defined) rules for all 28 components. These pre-define display and min-height before JS registers the custom elements, preventing Cumulative Layout Shift (CLS = 0).
You get this for free — just import agentui.css. No additional action needed.
For additional CLS prevention on dynamic content:
/* Reserve space for async-loaded content areas */
#main-content {
contain: layout style;
min-height: 500px;
}
/* SOTA 2026: Lazy-render off-screen content */
/* Up to 7x rendering boost for long lists */
.card-list > au-card {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
/* Prevent scrollbar CLS */
.scrollable-container {
scrollbar-gutter: stable;
}Use CSS preload and async JS for zero render-blocking:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Your app description">
<title>My App</title>
<!-- CSS: preload + async (non-render-blocking) -->
<link rel="preload" as="style" href="https://unpkg.com/agentui-wc@latest/dist/agentui.css">
<link rel="stylesheet" href="https://unpkg.com/agentui-wc@latest/dist/agentui.css"
media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://unpkg.com/agentui-wc@latest/dist/agentui.css"></noscript>
<!-- JS: async prevents render-blocking -->
<script type="module" src="https://unpkg.com/agentui-wc@latest/dist/agentui.esm.js" async></script>
<!-- Font: MD3 default -->
<style>
@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); margin: 0; }
</style>
</head>Why
media="print" onload? A regular<link rel="stylesheet">blocks rendering even with preload. Themedia="print"trick loads CSS without blocking, thenonloadswitches it toall. The built-in:not(:defined)rules prevent CLS during this async load.
Font loading: The @font-face with font-display: swap prevents invisible text during font load.
| Rule | Example |
|---|---|
Set lang on <html> |
<html lang="en"> |
Add <meta name="description"> |
Always include |
aria-label on icon-only buttons |
<au-button aria-label="Close">✕</au-button> |
Single <h1> per page |
Correct heading hierarchy |
alt on all <img> tags |
<img alt="User avatar"> |
| Color contrast ≥ 4.5:1 | MD3 tokens handle this by default |
AgentUI logs all component errors to window.__AGENTUI_ERRORS__:
// In E2E tests or debug console
const errors = window.__AGENTUI_ERRORS__ || [];
errors.forEach(err => {
console.error(`[${err.component}] ${err.message}`, err.details);
});Wrap async operations with proper error handling:
async function loadUserData() {
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
// Show user-friendly error
showToast('Failed to load data. Please retry.', { severity: 'error' });
// Log for debugging
console.error('loadUserData failed:', error);
return null;
}
}Create a wrapper to catch render errors:
function safeRender(container, renderFn) {
try {
container.innerHTML = renderFn();
} catch (error) {
container.innerHTML = `
<au-alert severity="error">
Something went wrong. Please refresh the page.
</au-alert>
`;
console.error('Render error:', error);
}
}
// Usage
safeRender(document.getElementById('content'), () => {
return `<au-card>${userData.name}</au-card>`;
});// Fetch with timeout and retry
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeout);
if (response.ok) return response;
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}// Enable verbose logging
window.AGENTUI_DEBUG = true;
// Check component registration
customElements.get('au-button'); // Returns class or undefined
// Inspect component state
const btn = document.querySelector('au-button');
console.log(btn.getAttribute('variant'), btn.disabled);
// Force component re-render
btn.render?.();// Catch all unhandled errors
window.addEventListener('error', (event) => {
console.error('Uncaught error:', event.error);
showToast('Something went wrong', { severity: 'error' });
event.preventDefault();
});
// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
showToast('Network or async error', { severity: 'warning' });
});// services/api.js
export async function fetchAPI(endpoint, options = {}) {
try {
const res = await fetch(`/api${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${res.status}`);
}
return res.json();
} catch (err) {
showToast(err.message || 'Network error', { severity: 'error' });
throw err;
}
}
// Usage
const users = await fetchAPI('/users');For AI Agents building apps with AgentUI. This is the recommended architecture.
Create a single file in app/pages/:
<!-- app/pages/users.html -->
<au-page route="users" title="Users">
<script type="x-dependencies">
au-table
au-button
au-stack
au-tabs
</script>
<template>
<h1 class="page-title">Users</h1>
<au-table id="users-table"></au-table>
<au-button onclick="addUser()">Add User</au-button>
</template>
</au-page># Build everything (framework + app)
bun run build
# Or separately:
bun run build:framework # → dist/
bun run build:app # → app-dist/
# Serve
npx serve app-distAgentUI/
├── src/ # Framework source
│ └── components/ # 57 components
│
├── dist/ # Framework build
│ ├── agentui.esm.js # Full bundle
│ ├── agentui.css # CSS
│ └── components/ # Per-component builds
│
├── app/ # App source
│ ├── pages/ # One file per page
│ │ ├── home.html
│ │ ├── users.html
│ │ └── settings.html
│ └── index.html # App shell
│
└── app-dist/ # App build (auto-generated)
├── routes/ # Auto-bundled from x-dependencies
├── pages/ # Copied HTML pages
└── pages.json # Manifest
- Agent creates
app/pages/mypage.htmlwith<au-page>component - Build extracts dependencies from
<script type="x-dependencies"> - Build generates route bundle in
app-dist/routes/mypage.js - Router lazy-loads page HTML and route bundle on navigation
<au-router base="/app-dist/pages" default="home"></au-router>| Attribute | Description | Default |
|---|---|---|
base |
Path to pages directory | /app/pages |
default |
Default route when no hash | home |
Events: au-route-change, au-page-loaded, au-page-error
Methods: .navigate(route)
<au-page route="mypage" title="My Page">
<script type="x-dependencies">au-button, au-card</script>
<template><!-- content --></template>
</au-page>| Attribute | Description |
|---|---|
route |
Route name (used in URL hash) |
title |
Page title (set on navigation) |
# 1. Create page file
cat > app/pages/dashboard.html << 'EOF'
<au-page route="dashboard" title="Dashboard">
<script type="x-dependencies">
au-card
au-stack
au-grid
</script>
<template>
<h1 class="page-title">Dashboard</h1>
<au-grid cols="3" gap="md">
<au-card variant="elevated">Widget 1</au-card>
<au-card variant="elevated">Widget 2</au-card>
<au-card variant="elevated">Widget 3</au-card>
</au-grid>
</template>
</au-page>
EOF
# 2. Build
bun run build:app
# 3. Navigate to: http://localhost:5001/app-dist/#dashboardFor apps with shared state across components and pages. AgentUI uses the EventBus (bus) for inter-component communication. For app state, use plain JavaScript objects + bus notifications.
// store/index.js - Centralized state with bus notifications
import { bus } from 'agentui-wc';
const state = {
user: null,
items: [],
loading: false
};
// Derived values (plain getters)
export const getItemCount = () => state.items.length;
export const isAuthenticated = () => state.user !== null;
// Actions — mutate state, then notify via bus
export async function fetchItems() {
state.loading = true;
bus.emit('state:change', { ...state });
try {
state.items = await fetch('/api/items').then(r => r.json());
} catch (err) {
console.error('Failed to fetch items:', err);
} finally {
state.loading = false;
bus.emit('state:change', { ...state });
}
}
export function login(userData) {
state.user = userData;
localStorage.setItem('user', JSON.stringify(userData));
bus.emit('auth:change', { user: userData });
}
export function logout() {
state.user = null;
localStorage.removeItem('user');
bus.emit('auth:change', { user: null });
}
// Restore on load
const savedUser = localStorage.getItem('user');
if (savedUser) state.user = JSON.parse(savedUser);
export { state };import { bus } from 'agentui-wc';
// Subscribe to state changes via bus
bus.on('state:change', (state) => {
document.querySelector('#user-name').textContent = state.user?.name || 'Guest';
document.querySelector('#login-btn').hidden = !!state.user;
document.querySelector('#logout-btn').hidden = !state.user;
document.querySelector('#spinner').hidden = !state.loading;
});export class AuUserCard extends AuElement {
#unsubscribe = null;
connectedCallback() {
super.connectedCallback();
// Subscribe to auth changes via bus
this.#unsubscribe = bus.on('auth:change', ({ user }) => {
this.user = user;
this.render();
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.#unsubscribe?.();
}
}Components that render dynamic data from arrays/objects.
<au-if condition>
<p>Visible when condition is true</p>
</au-if>
<!-- With else template -->
<au-if id="auth-guard" condition else="login-tpl">
<p>Welcome back!</p>
</au-if>
<template id="login-tpl">
<au-alert severity="info">Please log in.</au-alert>
</template>
<script type="module">
// Programmatic control
const guard = document.getElementById('auth-guard');
guard.condition = isLoggedIn; // true = show children, false = show else template
// Events
guard.addEventListener('au-show', () => console.log('Content visible'));
guard.addEventListener('au-hide', () => console.log('Content hidden'));
</script>Key points:
- Children are truly removed from DOM (not
display: none) - Same DOM nodes are restored on toggle (identity preserved)
elseattribute references a<template>by ID (XSS-safe:getElementByIdonly)display: contents— zero layout impact
<au-repeat id="user-list"></au-repeat>
<script type="module">
const list = document.getElementById('user-list');
// Set data and render template
list.items = await fetch('/api/users').then(r => r.json());
list.renderItem = (user, index) => `
<au-card variant="outlined">
<au-stack direction="row" gap="md" align="center">
<au-avatar initials="${user.name[0]}" size="md"></au-avatar>
<au-stack gap="xs">
<strong>${user.name}</strong>
<small>${user.email}</small>
</au-stack>
<au-button variant="text" data-id="${user.id}">Edit</au-button>
</au-stack>
</au-card>
`;
// Listen for item events
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-id]');
if (btn) console.log('Edit user:', btn.dataset.id);
});
</script><au-show id="panel" condition>
<form>
<!-- Form values, scroll position, timers all survive toggle -->
<au-input label="Name" value="Alice"></au-input>
</form>
</au-show>
<script type="module">
const panel = document.getElementById('panel');
panel.condition = false; // hidden via display:none, stays in DOM
panel.condition = true; // visible again, all state intact
panel.addEventListener('au-show', () => console.log('Visible'));
panel.addEventListener('au-hide', () => console.log('Hidden'));
</script>Key points:
- Children hidden with
display: none— not removed from DOM - Internal state (form values, event listeners, timers) preserved
- Use
au-ifwhen you want true DOM removal;au-showwhen toggling frequently display: contents— zero layout impact
<!-- Source: portal lives here but children render elsewhere -->
<div style="overflow: hidden;">
<au-portal target="#modal-container">
<au-modal open title="Escaped!">
<p>This modal is not clipped by overflow:hidden</p>
</au-modal>
</au-portal>
</div>
<!-- Target: teleported content appears here -->
<div id="modal-container"></div>
<script type="module">
const portal = document.querySelector('au-portal');
portal.addEventListener('au-teleport', () => console.log('Moved'));
portal.addEventListener('au-return', () => console.log('Returned'));
// Children auto-return on disconnect (cleanup)
</script>Key points:
- Children are moved (not cloned) — DOM identity preserved
- Solves
overflow: hiddenand z-index stacking context issues - Auto-cleanup: children return to source on disconnect
targetdefaults todocument.bodywhen absent
<!-- Lazy-load pattern -->
<au-intersection once threshold="0.1">
<img data-src="hero.webp" alt="Hero" />
</au-intersection>
<script type="module">
document.querySelectorAll('au-intersection').forEach(el => {
el.addEventListener('au-visible', (e) => {
console.log('Ratio:', e.detail.ratio);
const img = el.querySelector('img[data-src]');
if (img) { img.src = img.dataset.src; }
});
el.addEventListener('au-hidden', () => {
console.log('Left viewport');
});
});
</script>Key points:
- Declarative
IntersectionObserver— no manual setup/cleanup oncemode disconnects after first intersection (lazy-load)threshold(0–1) controls visibility ratio triggerroot-marginextends the viewport bounds (e.g."200px"for preloading)- Read
el.isVisiblefor current state
<!-- Desktop-only sidebar -->
<au-media query="(min-width: 768px)">
<aside class="sidebar"><nav>...</nav></aside>
</au-media>
<!-- Mobile-only bottom nav -->
<au-media query="(max-width: 767px)">
<au-bottom-nav>
<au-nav-item icon="home">Home</au-nav-item>
</au-bottom-nav>
</au-media>
<script type="module">
const media = document.querySelector('au-media');
console.log(media.matches); // true/false
media.addEventListener('au-match', () => console.log('Query matches'));
media.addEventListener('au-unmatch', () => console.log('Query no longer matches'));
</script>Key points:
- Children truly removed from DOM when query doesn't match (like
au-if) - Same DOM nodes restored on match (identity preserved)
- More efficient than CSS
display: nonefor heavy components - Read
el.matchesfor current state
<style>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-active { opacity: 0; }
</style>
<au-transition name="fade" active>
<div>I animate in and out!</div>
</au-transition>
<script type="module">
const transition = document.querySelector('au-transition');
transition.active = true; // applies fade-enter-from → fade-enter-active
transition.active = false; // applies fade-leave-from → fade-leave-active
transition.addEventListener('au-enter', () => console.log('Enter started'));
transition.addEventListener('au-leave', () => console.log('Leave started'));
</script>Key points:
- Vue-inspired class naming:
{name}-enter-from,{name}-enter-active,{name}-leave-from,{name}-leave-active - Does not define visual CSS — you provide the transition styles
nameattribute sets the class prefix (default:au)display: contents— zero layout impact
<!-- Count-up timer -->
<au-timer id="stopwatch" interval="1000" autostart></au-timer>
<!-- Countdown timer (30 seconds) -->
<au-timer id="countdown" interval="1000" countdown="30"></au-timer>
<script type="module">
const timer = document.getElementById('stopwatch');
timer.addEventListener('au-tick', (e) => {
display.textContent = e.detail.count; // 1, 2, 3...
});
timer.start();
timer.stop();
timer.reset();
const countdown = document.getElementById('countdown');
countdown.addEventListener('au-tick', (e) => {
display.textContent = e.detail.count; // 30, 29, 28...
});
countdown.addEventListener('au-complete', () => {
showToast('Time is up!', { severity: 'warning' });
});
countdown.start();
</script>Key points:
- Automatic
clearIntervalon disconnect — no memory leaks countdownattribute enables count-down mode (firesau-completeat 0)autostartstarts timer on connect- Interval clamped to ≥100ms for safety
- Read
el.countandel.runningfor current state
<au-table id="data-table"></au-table>
<script type="module">
const table = document.getElementById('data-table');
// Configure columns
table.columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Role', sortable: true },
{ key: 'actions', label: '', render: (row) => `
<au-button variant="text" size="sm" data-action="edit" data-id="${row.id}">
<au-icon name="edit"></au-icon>
</au-button>
`}
];
// Set data
table.data = await fetch('/api/users').then(r => r.json());
// Handle actions
table.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (btn?.dataset.action === 'edit') {
console.log('Edit:', btn.dataset.id);
}
});
</script>const list = document.querySelector('au-virtual-list');
list.items = hugeArray; // 10,000+ items OK
list.itemHeight = 56; // Fixed height for virtualization
list.renderItem = (item) => `
<div class="list-row">${item.name}</div>
`;Recommended structure for complex multi-page applications.
my-app/
├── index.html # App shell (minimal HTML)
├── sw.js # Service Worker
├── manifest.json # PWA manifest
├── store/
│ └── index.js # Global state (plain object + bus)
├── pages/
│ ├── home.js # Lazy-loaded route modules
│ ├── users.js
│ ├── settings.js
│ └── not-found.js
├── components/
│ ├── user-card.js # Reusable custom components
│ ├── data-table.js
│ └── nav-menu.js
├── services/
│ └── api.js # API client with error handling
└── assets/
├── logo.webp
└── icons/
<!-- index.html - App Shell -->
<body>
<au-navbar>...</au-navbar>
<main id="app"></main>
<au-toast-container></au-toast-container>
<script type="module">
// Route configuration
const routes = {
home: () => import('./pages/home.js'),
users: () => import('./pages/users.js'),
settings: () => import('./pages/settings.js'),
default: () => import('./pages/not-found.js')
};
async function navigate() {
const hash = location.hash.slice(1) || 'home';
const loader = routes[hash] || routes.default;
try {
const module = await loader();
document.getElementById('app').innerHTML = '';
await module.render(document.getElementById('app'));
} catch (err) {
console.error('Route error:', err);
document.getElementById('app').innerHTML = `
<au-alert severity="error">Failed to load page</au-alert>
`;
}
}
window.addEventListener('hashchange', navigate);
navigate(); // Initial load
</script>
</body>// pages/users.js
import { store } from '../store/index.js';
import { fetchAPI } from '../services/api.js';
export async function render(container) {
// Show skeleton while loading
container.innerHTML = `
<au-doc-page title="Users">
<au-skeleton variant="text" lines="5"></au-skeleton>
</au-doc-page>
`;
// Fetch data
const users = await fetchAPI('/users');
store.users.set(users);
// Render content
container.innerHTML = `
<au-doc-page title="Users">
<au-stack gap="md">
<au-stack direction="row" justify="space-between">
<h2>All Users (${users.length})</h2>
<au-button variant="filled" id="add-user">Add User</au-button>
</au-stack>
<au-repeat id="user-list"></au-repeat>
</au-stack>
</au-doc-page>
`;
// Setup repeat
const list = container.querySelector('#user-list');
list.items = users;
list.renderItem = (user) => `
<au-card variant="outlined">
<strong>${user.name}</strong> - ${user.email}
</au-card>
`;
}[ ] Use route-based code splitting (only load needed code)
[ ] Preload critical routes with <link rel="prefetch">
[ ] Use au-virtual-list for lists with 100+ items
[ ] Use au-lazy for below-the-fold content
[ ] Implement skeleton loading for perceived performance
[ ] Cache API responses with Service Worker
[ ] Use bus for cross-component state (not prop drilling)
[ ] Minimize bundle size with tree-shaking
All 57 AgentUI components at a glance. Key attributes and copy-paste examples.
| Component | Key Attributes | Example |
|---|---|---|
au-button |
variant (filled/outlined/text/tonal), disabled, icon |
<au-button variant="filled">Save</au-button> |
au-chip |
selected, removable, static, variant |
<au-chip selected>Active</au-chip> <au-chip static>Badge</au-chip> |
au-icon |
name (MD icon name) |
<au-icon name="home"></au-icon> |
Note
Icons: AgentUI bundles ~50 common icons (home, settings, edit, delete, etc.).
Non-bundled icons use Google Fonts fallback. To use all Material Icons, add:
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
| Component | Key Attributes | Example |
|---|---|---|
au-input |
label, type, value, variant (outlined/filled) |
<au-input label="Email" type="email"></au-input> |
au-textarea |
label, rows, value |
<au-textarea label="Notes" rows="4"></au-textarea> |
au-checkbox |
checked, label, disabled |
<au-checkbox label="Accept terms"></au-checkbox> |
au-switch |
checked, label |
<au-switch label="Dark mode"></au-switch> |
au-radio |
name, value, checked |
<au-radio name="size" value="lg">Large</au-radio> |
au-dropdown |
label, placeholder |
<au-dropdown label="Country"><option>Italy</option></au-dropdown> |
au-form |
(none) | <au-form><au-input label="Name"></au-input></au-form> |
au-schema-form |
schema (JSON Schema) |
<au-schema-form schema="..."></au-schema-form> |
| Component | Key Attributes | Example |
|---|---|---|
au-layout |
(slots: header, drawer, main), full-bleed |
<au-layout full-bleed><!-- zero-padding --></au-layout> |
au-container |
max-width |
<au-container max-width="lg">...</au-container> |
au-stack |
gap (xs/sm/md/lg/xl), direction |
<au-stack gap="md">...</au-stack> |
au-grid |
cols, gap |
<au-grid cols="3" gap="md">...</au-grid> |
au-divider |
vertical |
<au-divider></au-divider> |
| Component | Key Attributes | Example |
|---|---|---|
au-drawer |
mode (auto/permanent/temporary/rail), open |
<au-drawer mode="auto">...</au-drawer> |
au-drawer-item |
icon, href, active |
<au-drawer-item icon="home" active>Home</au-drawer-item> |
au-navbar |
title |
<au-navbar title="My App"></au-navbar> |
au-sidebar |
(similar to drawer) | <au-sidebar>...</au-sidebar> |
au-bottom-nav |
(slots for items) | <au-bottom-nav>...</au-bottom-nav> |
au-tabs |
active (index) |
<au-tabs><au-tab>Tab 1</au-tab></au-tabs> |
au-router |
routes |
<au-router routes="..."></au-router> |
| Component | Key Attributes | Example |
|---|---|---|
au-card |
variant (elevated/filled/outlined) |
<au-card variant="elevated">...</au-card> |
au-callout |
variant (info/warning/error/success), title |
<au-callout variant="warning">Attention!</au-callout> |
au-alert |
severity (info/success/warning/error), dismissible |
<au-alert severity="error">Failed!</au-alert> |
au-badge |
value, variant |
<au-badge value="3">Notifications</au-badge> |
au-avatar |
src, alt, size |
<au-avatar src="user.jpg"></au-avatar> |
au-tooltip |
content, position |
<au-tooltip content="Help">?</au-tooltip> |
au-code |
lang, inline |
<au-code lang="js">const x = 1;</au-code> |
au-table |
data |
<au-table data="..."></au-table> |
au-datatable |
data, columns, sortable |
<au-datatable data="..."></au-datatable> |
au-api-table |
data |
<au-api-table data="..."></au-api-table> |
| Component | Key Attributes | Example |
|---|---|---|
au-modal |
open, title |
<au-modal open title="Confirm">...</au-modal> |
au-confirm |
title, message |
<au-confirm title="Delete?"></au-confirm> |
au-toast |
severity, duration |
showToast('Saved!', { severity: 'success' }) |
au-spinner |
size |
<au-spinner size="lg"></au-spinner> |
au-progress |
value, max, indeterminate |
<au-progress value="50" max="100"></au-progress> |
au-skeleton |
variant (text/circular/rectangular) |
<au-skeleton variant="text"></au-skeleton> |
| Component | Key Attributes | Example |
|---|---|---|
au-theme-toggle |
(none) | <au-theme-toggle></au-theme-toggle> |
au-lazy |
src |
<au-lazy src="heavy-component.js"></au-lazy> |
au-fetch |
url, method |
<au-fetch url="/api/data"></au-fetch> |
au-repeat |
items, template |
<au-repeat items="...">...</au-repeat> |
au-if |
condition, else |
<au-if condition>Visible</au-if> |
au-show |
condition |
<au-show condition>Hidden with display:none</au-show> |
au-portal |
target |
<au-portal target="#container">Teleported</au-portal> |
au-intersection |
threshold, root-margin, once |
<au-intersection once>Lazy load</au-intersection> |
au-media |
query |
<au-media query="(min-width: 768px)">Desktop only</au-media> |
au-transition |
name, active |
<au-transition name="fade" active>Animated</au-transition> |
au-timer |
interval, countdown, autostart |
<au-timer interval="1000" autostart></au-timer> |
au-virtual-list |
items, item-height |
<au-virtual-list items="..." item-height="50"></au-virtual-list> |
au-error-boundary |
fallback |
<au-error-boundary fallback="Error">...</au-error-boundary> |
| Component | Key Attributes | Example |
|---|---|---|
au-example |
title |
<au-example title="Button Demo">...</au-example> |
au-doc-page |
title |
<au-doc-page title="API">...</au-doc-page> |
au-page |
id |
<au-page id="home">...</au-page> |
au-prompt-ui |
(internal) | (Used for AI agent UI) |
<!-- Latest version (non-render-blocking) -->
<link rel="preload" as="style" href="https://unpkg.com/agentui-wc@latest/dist/agentui.css">
<link rel="stylesheet" href="https://unpkg.com/agentui-wc@latest/dist/agentui.css"
media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://unpkg.com/agentui-wc@latest/dist/agentui.css"></noscript>
<script type="module" src="https://unpkg.com/agentui-wc@latest/dist/agentui.esm.js" async></script>| Problem | Solution |
|---|---|
| Components not rendering | Ensure <script type="module"> is used |
| Styles missing | Include agentui.css before components |
npm package missing dist/ |
Update to v0.1.20+ (fixed) |
| Label overlaps input text | Update to v0.1.22+ (has-value fix) |
| Dropdown shows no options | Use <au-option> or standard <option> tags (v0.1.21+) |
Last updated: v0.1.150 - 2026-02-17