The progressive enhancement model
Kiwa UI components render to plain HTML with data-* attributes that describe their state and behavior. That HTML works on its own. Dialogs open with a server roundtrip, dropdowns fall back to native <details>, and so on.
When you want richer interaction (keyboard nav, focus trap, ARIA state, fluid open/close), load the matching behavior from @kiwa-ui/enhance. It scans the DOM for the data attributes and wires everything up. No framework, no virtual DOM, no hydration step.
Install enhance
Every behavior is a separate export, so bundlers can tree-shake what you don't use. Import from the root package or from per-behavior subpath exports.
pnpm add @kiwa-ui/enhanceWire it up
Drop a module script into your Hono layout that imports and calls the behaviors you need. Each call scans the DOM once and attaches listeners.
<script type="module">
import { dialog, dropdown, tabs, clipboard } from '@kiwa-ui/enhance'
dialog()
dropdown()
tabs()
clipboard()
</script>Call the behaviors you actually use. Don't blanket-import everything. For apps with a lot of dynamic content, call a behavior again after inserting new DOM.
Available behaviors
26 behaviors ship with @kiwa-ui/enhance. Each maps to a primitive or block that uses the matching data attribute.
| Behavior | Description |
|---|---|
dialog | Modal dialogs with focus trap and ESC close. |
dropdown | Menus with keyboard nav and typeahead. |
tabs | Tab panels with roving focus. |
accordion | Expand/collapse sections. |
collapsible | Standalone show/hide. |
toggle | Two-state buttons. |
tooltip | Hover/focus tooltips with smart positioning. |
popover | Floating panels anchored to triggers. |
sheet | Slide-out side panels. |
slider | Range input with keyboard support. |
command | Command palette / fuzzy search. |
alert-dialog | Confirmation dialogs. |
context-menu | Right-click menus. |
hover-card | Rich tooltips on hover. |
carousel | Horizontal scroll with snap. |
sidebar | Collapsible app sidebar. |
sidebar-mobile | Mobile drawer variant. |
popover-submenu | Nested popover menus. |
date-picker | Calendar input. |
select | Custom select dropdown. |
editor | TipTap-based rich text (optional peer deps). |
selectable-table | Row selection with shift-click. |
chart-tooltip | Tooltips for chart primitives. |
theme | Dark/light mode toggle. |
clipboard | Copy-to-clipboard buttons. |
toast | Transient notifications. |
Data-attribute conventions
Every behavior follows the same attribute shape so the markup reads the same across primitives.
| Attribute | Description |
|---|---|
data-<behavior> | The root container. |
data-<behavior>-trigger | The element that opens/toggles the surface. |
data-<behavior>-content | The panel/menu/content region. |
data-stateanddata-<behavior>-open | Reflect current state for CSS styling. |
<button data-dialog-trigger='welcome'>Open</button>
<div data-dialog='welcome' data-state='closed'>
<div data-dialog-overlay />
<div data-dialog-content>
<h2>Welcome</h2>
<button data-dialog-close>Close</button>
</div>
</div>Without enhance vs with enhance
The HTML renders. The trigger is focusable and announces itself correctly. The dialog surface is hidden by default via CSS that targets [data-state="closed"]. Clicking the trigger does nothing until JS loads, so for critical flows, fall back to a server-rendered route.
Calling dialog() attaches click handlers to every [data-dialog-trigger], manages focus trap, handles ESC to close, toggles data-state, and restores focus to the trigger on close. All without changing your markup.
Writing your own enhancers
Enhancers are just functions that query for a data attribute and attach listeners. Use the ones in packages/enhance/src/ as a reference. They're all ~50-200 lines of plain TypeScript. A minimal example:
// enhance-copy.ts
export function copy() {
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', async () => {
const text = btn.getAttribute('data-copy-text') ?? ''
await navigator.clipboard.writeText(text)
btn.setAttribute('data-copy-copied', 'true')
setTimeout(() => btn.removeAttribute('data-copy-copied'), 1500)
})
})
}