14 KiB
PettyUI v2: Web Components Architecture
Overview
PettyUI v2 is a complete rewrite from SolidJS to vanilla Web Components. Zero framework dependencies. Zero external runtime. Built on modern web platform APIs (Popover, <dialog>, Invoker Commands, Navigation API, View Transitions). AI-native with Zod schemas and MCP tools.
Goal: The smallest, fastest headless UI component library that exists, because it builds on the browser instead of replacing it. Capable of powering full SPA experiences with zero client-side framework.
Non-goals: Styled components. Shadow DOM. Framework-specific bindings. Build step requirements.
Architecture
Core Principles
- Browser-first — use native APIs before writing JS. Popover API for overlays,
<dialog>for modals, Invoker Commands for triggers, Navigation API for routing, View Transitions for animations,<details>for disclosure. - Progressive enhancement — components that can work before JS loads DO work before JS loads. JS upgrades add ARIA, keyboard navigation, and programmatic APIs.
- Zero dependencies — no Lit, no Floating UI, no framework. The only runtime is a ~30-line signals utility (~500 bytes) bundled inline.
- AI-native — Zod schemas describe every component's API. MCP tools (discover, inspect, compose, validate, add) let AI agents generate correct HTML without reading docs.
- Full SPA capability — Navigation API + View Transitions replace client-side routers. PettyUI apps feel like SPAs with zero framework overhead.
Tech Stack
- Language: TypeScript, compiled to ES modules
- Reactivity: Homegrown signals (~30 lines, ~500 bytes), aligned with TC39 Signals proposal
- Components: Custom Elements v1 (no Shadow DOM)
- Positioning: Simple CSS (relative/absolute). Consumer adds Floating UI if they need viewport-edge flipping — that's a styling concern, not a library concern.
- Overlays: Popover API +
<dialog>element - Triggers: Invoker Commands API (
commandfor/commandattributes) - Routing: Navigation API (browser-native SPA router, Baseline Jan 2026)
- Transitions: View Transitions API (smooth page/component animations, Baseline 2025)
- Build: tsdown (same as v1)
- Testing: Vitest + @web/test-runner (real browser tests for Custom Elements)
- Validation: Zod v4 schemas (carried over from v1)
Signals Utility
The entire reactivity system, bundled with the library (not an external dep):
let current: (() => void) | null = null;
interface Signal<T> {
get(): T;
set(value: T): void;
}
function signal<T>(value: T): Signal<T> {
const subs = new Set<() => void>();
return {
get() { if (current) subs.add(current); return value; },
set(v: T) { value = v; for (const fn of subs) fn(); },
};
}
function effect(fn: () => void): void {
const prev = current;
current = fn;
fn();
current = prev;
}
Components use signal() for internal state and effect() to sync state to DOM attributes/properties.
Component Anatomy
Three Tiers
Tier 1 — HTML-native (works without JS):
- Dialog, Popover, Tooltip, Collapsible/Accordion (via
<details>), Alert, Separator, Link - JS upgrade adds: ARIA linking, keyboard nav, custom events, programmatic API
Tier 2 — Popover-enhanced (partially works without JS):
- Select, DropdownMenu, ContextMenu, HoverCard, DatePicker, CommandPalette
- Popover API handles open/close. JS adds: keyboard nav, typeahead, ARIA, selection state
Tier 3 — JS-required (inert until upgrade):
- Tabs, Slider, Calendar, DataTable, VirtualList, Combobox, Wizard, Form, NumberField, Toast, Pagination
- These components need JS for core functionality. They render as plain HTML until the custom element upgrades.
Composition: Nested Custom Elements
Complex components use nested custom elements. Each part is a registered element with its own lifecycle. Parts find their parent via this.closest().
<petty-dialog>
<button commandfor="my-dlg" command="show-modal">Open</button>
<dialog id="my-dlg">
<petty-dialog-title>Confirm</petty-dialog-title>
<petty-dialog-description>Are you sure?</petty-dialog-description>
<button commandfor="my-dlg" command="close">Cancel</button>
</dialog>
</petty-dialog>
Rule: If a part needs its own behavior (keyboard handling, ARIA management, event emission), it's a custom element. If it's pure structure, it's a plain element with data-part for styling hooks.
Data Attributes
Following the established convention (Radix, Shoelace):
data-state— component state:open,closed,active,inactive,checked,unchecked,on,offdata-part— structural identification for CSS targetingdata-disabled— disabled state flagdata-orientation—horizontalorvertical
Event Pattern
Components dispatch standard CustomEvents with the petty- prefix:
this.dispatchEvent(new CustomEvent("petty-close", {
bubbles: true,
detail: { value: "confirm" },
}));
Consumers listen with standard DOM:
dialog.addEventListener("petty-close", (e) => {
if (e.detail.value === "confirm") deleteItem();
});
Property/Attribute Reflection
Components expose both property and attribute APIs:
// Attribute API (HTML)
<petty-select value="apple">
// Property API (JS)
document.querySelector("petty-select").value = "apple";
Properties are the source of truth. Attributes reflect via attributeChangedCallback → property setter. This avoids the string-only limitation of attributes for complex values.
Component List (44 components, same as v1)
Layout & Display (6)
petty-avatar— image with fallbackpetty-badge— status indicator (simple element, no sub-parts)petty-card— container withpetty-card-header,petty-card-content,petty-card-footerpetty-image— image with fallbackpetty-separator—<hr>with role and orientationpetty-skeleton— loading placeholder
Inputs & Forms (14)
petty-button—<button>wrapper with loading/disabled statepetty-text-field— label + input + description + errorpetty-number-field— input with increment/decrementpetty-checkbox— tri-state checkboxpetty-switch— on/off togglepetty-radio-group+petty-radio-item— mutually exclusive optionspetty-slider— range input with track/thumbpetty-toggle— pressed/unpressed buttonpetty-toggle-group+ items — single/multi toggle selectionpetty-select+petty-select-trigger,petty-select-content,petty-select-option— dropdown select using Popover APIpetty-combobox+ parts — searchable selectpetty-listbox+petty-listbox-option— inline selectable listpetty-form+petty-form-field— Zod-validated formpetty-date-picker+ parts — date input with calendar popover
Navigation (8)
petty-link— anchor with external/disabled supportpetty-breadcrumbs+petty-breadcrumb-item— navigation trailpetty-tabs+petty-tab,petty-tab-panel— tabbed interfacepetty-accordion+petty-accordion-item— collapsible sections (uses<details>internally)petty-collapsible— single disclosure (uses<details>internally)petty-pagination+ parts — page navigationpetty-nav-menu+ parts — horizontal nav with dropdownspetty-wizard+ parts — multi-step flow
Overlays (9)
petty-dialog— wraps native<dialog>with ARIA + eventspetty-alert-dialog— confirmation dialogpetty-drawer— slide-in panel (uses<dialog>)petty-popover— floating content (uses Popover API)petty-tooltip— hover label (uses Popover API + CSS Anchor)petty-hover-card— rich hover previewpetty-dropdown-menu+ items — action menu (uses Popover API)petty-context-menu+ items — right-click menupetty-command-palette+ parts — search-driven command menu
Feedback & Status (4)
petty-alert— inline status messagepetty-toast+petty-toast-region— temporary notificationpetty-progress— progress barpetty-meter— value gauge
Data (3)
petty-calendar+ parts — month grid date pickerpetty-data-table+ parts — sortable/filterable tablepetty-virtual-list— windowed scroll
SPA Router (petty-router)
PettyUI includes an optional ~15-line SPA router built entirely on browser standards. No external dependency.
How it works
The Navigation API (Baseline January 2026) provides browser-native SPA routing. View Transitions API (Baseline 2025) animates page swaps. Together they replace React Router, Vue Router, and every other client-side routing library.
// petty-router: the entire implementation
navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
if (url.origin !== location.origin) return;
event.intercept({
async handler() {
const res = await fetch(url.pathname, {
headers: { "X-Partial": "true" }
});
const html = await res.text();
document.startViewTransition(() => {
document.querySelector("[data-petty-outlet]").innerHTML = html;
});
}
});
});
Consumer usage
<script type="module">
import "pettyui/router";
</script>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
<main data-petty-outlet>
<!-- Content swapped here on navigation -->
</main>
Clicking a link: Navigation API intercepts → fetches HTML from server → View Transitions animates the swap → PettyUI components in the new HTML upgrade instantly. Back/forward buttons work. URL bar updates. No full page reload.
What the server does
The server checks for the X-Partial header. If present, it returns just the page fragment (no <html>, <head>, etc.). If absent, it returns the full page (for direct URL visits, bookmarks, SEO crawlers).
Browser support
| API | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Navigation API | 102+ | 136+ | 18.2+ | 102+ |
| View Transitions (same-doc) | 111+ | 140+ | 18+ | 111+ |
Both are Baseline. For the rare old browser without support, links just work as normal full-page navigations — progressive enhancement.
Platform APIs Usage Map
| Browser API | Replaces (v1) | Used by |
|---|---|---|
| Popover API | createDismiss(), z-index management |
Select, DropdownMenu, ContextMenu, Tooltip, Popover, HoverCard, CommandPalette, DatePicker |
<dialog> |
createFocusTrap(), scroll lock, backdrop |
Dialog, AlertDialog, Drawer |
| Invoker Commands | JS click handlers for open/close | Dialog, AlertDialog, Popover triggers |
| Navigation API | React Router / client-side routers | petty-router (SPA navigation) |
| View Transitions | Animation JS / Framer Motion | petty-router (page transitions), Accordion, Dialog enter/exit |
<details>/<summary> |
Disclosure state management | Accordion, Collapsible |
inert attribute |
Manual aria-hidden + tabindex management | Dialog background freeze |
File Structure
packages/core/
src/
signals.ts — signal() + effect() (~30 lines)
router.ts — petty-router SPA navigation (~15 lines)
shared/
keyboard.ts — shared keyboard navigation utilities
aria.ts — shared ARIA helpers
components/
dialog/
dialog.ts — PettyDialog custom element
dialog-title.ts — PettyDialogTitle
dialog-description.ts
dialog.schema.ts — Zod schema (carried from v1)
dialog.test.ts
index.ts — registration + exports
select/
select.ts
select-trigger.ts
select-content.ts
select-option.ts
select.schema.ts
select.test.ts
index.ts
... (same pattern for all 44)
index.ts — registers all elements, exports all
package.json
tsconfig.json
Each component is independently importable:
import "pettyui/dialog"; // registers <petty-dialog> etc.
Or consumers include a script tag:
<script type="module" src="https://cdn.jsdelivr.net/npm/pettyui/dialog"></script>
Migration from v1
What carries over unchanged
- Zod schemas (props definitions, validation)
- MCP tools architecture (discover, inspect, compose, validate, add)
- Component registry metadata
- Test scenarios (same behaviors, different implementation)
- NagLint rules
What gets rewritten
- All 44 component implementations (SolidJS JSX → Custom Elements)
- Utility primitives (createFloating → CSS anchor, createDismiss → Popover API, createFocusTrap →
<dialog>) - Test harness (SolidJS testing library → DOM-based testing)
What gets deleted
solid-jspeer dependency@floating-ui/domdependencyvite-plugin-solid- All
.tsxfiles (replaced by.ts) - Compound component Object.assign pattern
Phased Rollout
Phase 1: Foundation
- Signals utility
- Shared utilities (keyboard, ARIA, anchor fallback)
- Base component patterns established
Phase 2: Simple Components (Tier 1 + easy Tier 3)
- Dialog, Alert, AlertDialog, Collapsible, Accordion
- Button, Badge, Separator, Link, Toggle, Skeleton, Avatar, Image
- Progress, Meter
Phase 3: Selection & Input Components
- Select, Combobox, Listbox
- TextField, NumberField, Checkbox, Switch, RadioGroup, Slider
- ToggleGroup, Form
Phase 4: Complex Components
- Tabs, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu
- Drawer, CommandPalette, DatePicker, Calendar
- Pagination, NavigationMenu, Wizard, Breadcrumbs
Phase 5: Data Components + MCP Update
- DataTable, VirtualList, Toast
- Update MCP tools for new architecture
- Update registry
Size Budget
| Item | v1 (SolidJS) | v2 (Web Components) |
|---|---|---|
| Framework runtime | solid-js ~23KB | 0KB |
| Floating UI | @floating-ui/dom ~15KB | 0KB (simple CSS) |
| Router | N/A (framework-specific) | ~400 bytes (Navigation API wrapper) |
| Signals utility | (included in solid-js) | ~500 bytes |
| Per-component avg | ~2-4KB | ~0.5-2KB |
| Total (all 44 + router) | ~80-100KB | ~20-35KB |
Success Criteria
- Zero external runtime dependencies
- Every component passes existing accessibility test scenarios
- Tier 1 components function without JavaScript
- Total gzipped bundle (all 44 components) under 15KB
- INP < 100ms on component interactions (no hydration)
- MCP tools work with the new architecture
- Works in Chrome, Firefox, Safari, Edge (latest 2 versions)