Headless custom elements built on Zag. No shadow DOM, no framework, no styles you did not write.
You supply the markup and the CSS. The package supplies state, keyboard handling and ARIA.
<link rel="stylesheet" href="node_modules/@bagistoplus/ui/ui.css" />import "@bagistoplus/ui/accordion";
import "@bagistoplus/ui/tabs";Each component is its own entry point, so you ship only what you use.
| Component | Import | Elements |
|---|---|---|
| Accordion | @bagistoplus/ui/accordion |
ui-accordion, ui-accordion-item, ui-accordion-item-trigger, ui-accordion-item-content, ui-accordion-item-indicator |
| Collapsible | @bagistoplus/ui/collapsible |
ui-collapsible, ui-collapsible-trigger, ui-collapsible-indicator, ui-collapsible-content |
| Tabs | @bagistoplus/ui/tabs |
ui-tabs, ui-tabs-list, ui-tabs-trigger, ui-tabs-content, ui-tabs-indicator |
| Popover | @bagistoplus/ui/popover |
ui-popover, ui-popover-anchor, ui-popover-trigger, ui-popover-indicator, ui-popover-positioner, ui-popover-content, ui-popover-title, ui-popover-description, ui-popover-close-trigger, ui-popover-arrow, ui-popover-arrow-tip |
| Dialog | @bagistoplus/ui/dialog |
ui-dialog, ui-dialog-trigger, ui-dialog-backdrop, ui-dialog-positioner, ui-dialog-content, ui-dialog-title, ui-dialog-description, ui-dialog-close-trigger |
| Menu | @bagistoplus/ui/menu |
ui-menu, ui-menu-trigger, ui-menu-indicator, ui-menu-positioner, ui-menu-content, ui-menu-arrow, ui-menu-arrow-tip, ui-menu-item, ui-menu-item-text, ui-menu-item-indicator, ui-menu-item-group, ui-menu-item-group-label, ui-menu-separator |
| Navigation Menu | @bagistoplus/ui/navigation-menu |
ui-navigation-menu, ui-navigation-menu-list, ui-navigation-menu-item, ui-navigation-menu-trigger, ui-navigation-menu-trigger-proxy, ui-navigation-menu-viewport-proxy, ui-navigation-menu-item-indicator, ui-navigation-menu-link, ui-navigation-menu-content, ui-navigation-menu-viewport-positioner, ui-navigation-menu-viewport, ui-navigation-menu-indicator, ui-navigation-menu-arrow |
| Carousel | @bagistoplus/ui/carousel |
ui-carousel, ui-carousel-item-group, ui-carousel-item, ui-carousel-control, ui-carousel-prev-trigger, ui-carousel-next-trigger, ui-carousel-autoplay-trigger, ui-carousel-indicator-group, ui-carousel-indicator, ui-carousel-progress-text |
| Marquee | @bagistoplus/ui/marquee |
ui-marquee, ui-marquee-viewport, ui-marquee-content, ui-marquee-item, ui-marquee-edge |
| Number Input | @bagistoplus/ui/number-input |
ui-number-input, ui-number-input-label, ui-number-input-control, ui-number-input-input, ui-number-input-increment-trigger, ui-number-input-decrement-trigger, ui-number-input-value-text, ui-number-input-scrubber |
| Before After | @bagistoplus/ui/before-after |
ui-before-after, ui-before-after-before, ui-before-after-after, ui-before-after-separator, ui-before-after-handle |
| Timer | @bagistoplus/ui/timer |
ui-timer, ui-timer-area, ui-timer-control, ui-timer-item, ui-timer-item-value, ui-timer-item-label, ui-timer-separator, ui-timer-action-trigger |
| Date Picker | @bagistoplus/ui/date-picker |
ui-date-picker, ui-date-picker-label, ui-date-picker-control, ui-date-picker-input, ui-date-picker-trigger, ui-date-picker-clear-trigger, ui-date-picker-positioner, ui-date-picker-content, ui-date-picker-view, ui-date-picker-view-control, ui-date-picker-view-trigger, ui-date-picker-prev-trigger, ui-date-picker-next-trigger, ui-date-picker-range-text, ui-date-picker-table, ui-date-picker-month-select, ui-date-picker-year-select, ui-date-picker-preset-trigger, plus the six grid parts the table writes |
| Image Zoom | @bagistoplus/ui/image-zoom |
ui-image-zoom, ui-image-zoom-viewport, ui-image-zoom-image, ui-image-zoom-increment-trigger, ui-image-zoom-decrement-trigger, ui-image-zoom-reset-trigger |
| Slider | @bagistoplus/ui/slider |
ui-slider, ui-slider-label, ui-slider-control, ui-slider-track, ui-slider-range, ui-slider-thumb, ui-slider-hidden-input, ui-slider-value-text, ui-slider-marker-group, ui-slider-marker, ui-slider-dragging-indicator |
The examples below use the accordion. Everything they show about delegate, styling and the api applies to every component.
<ui-accordion collapsible default-value="shipping">
<ui-accordion-item value="shipping">
<ui-accordion-item-trigger delegate>
<button class="flex w-full items-center justify-between p-4">
Shipping
<ui-accordion-item-indicator>
<span class="transition-transform data-[state=open]:rotate-180">▾</span>
</ui-accordion-item-indicator>
</button>
</ui-accordion-item-trigger>
<ui-accordion-item-content>
<div class="p-4">Ships in two working days.</div>
</ui-accordion-item-content>
</ui-accordion-item>
</ui-accordion>One rule, no exceptions:
Without
delegate, an element is a container: Zag's props land on it, you style it directly, and a class you write on it does what you expect.With
delegate, it hands those props to its single element child and goesdisplay: contents, costing no layout of its own.
Reach for delegate when the host cannot be the element the context requires. A custom element cannot be a <ul> or an <li>, so a real list delegates:
<ui-accordion delegate>
<ul>
<li>
<ui-accordion-item value="a">…</ui-accordion-item>
</li>
</ul>
</ui-accordion>A delegating element renders nothing and waits if it has no element child, because during HTML parsing it connects before its child exists. With more than one element child it warns and uses the first.
ui-accordion-item-trigger always needs it. Zag's trigger props say type="button", disabled and aria-expanded, but Enter, Space, focus rings and disabled pointer blocking come from the browser, and only a real <button> provides them. There is no tabindex either, so a container trigger cannot even be reached. It warns in the console if you forget.
Style the elements directly. Zag puts data-scope, data-part, data-state and hidden on whichever element took the props, so data-[state=open]: variants work where you write them, and ui-accordion-item[data-state="open"] styles a whole item by state. With delegate those attributes are on the child, so the class goes there too:
<!-- wrong: a delegating host is display:contents and carries no data-state -->
<ui-accordion-item-trigger delegate class="flex w-full data-[state=open]:bg-neutral-100">
<button>…</button>
</ui-accordion-item-trigger>
<!-- right -->
<ui-accordion-item-trigger delegate>
<button class="flex w-full data-[state=open]:bg-neutral-100">…</button>
</ui-accordion-item-trigger>Any display rule you write for ui-accordion-item-content needs :not([hidden]). Zag applies hidden to that element, and [hidden] { display: none } is a user agent rule, so any author rule beats it and leaves a closed panel visible and still tabbable.
On ui-accordion:
| attribute | type | meaning |
|---|---|---|
multiple |
boolean | more than one item open at a time |
collapsible |
boolean | an open item can be closed again |
disabled |
boolean | disables every item |
orientation |
vertical | horizontal |
arrow key axis, default vertical |
default-value |
comma list | items open on first render |
presence |
boolean | see below |
dir |
ltr | rtl |
inherited from the nearest [dir] ancestor when absent |
On ui-accordion-item: value (required, unique within the accordion) and disabled.
Every attribute is observed. Changing one updates the machine in place and keeps the current state, which is the point of this package: a server that re-renders the markup does not have to tear the component down.
There is deliberately no value attribute for controlled state. Use el.api.setValue().
Zag owns the id of every element it touches, so it writes ids onto items, triggers and panels to wire up aria-controls and aria-labelledby. The root is the exception: an id you author on ui-accordion is kept and used as Zag's root id, so #your-id keeps working.
el.api is the live Zag accordion api: value, setValue(), getItemState().
await customElements.whenDefined("ui-accordion");
const root = document.querySelector("ui-accordion");
root.api.setValue([...root.api.value, "returns"]);It is undefined before the element upgrades and connects.
This is Zag's own api, published as ours. A Zag major version is a major version here.
Zag's callbacks arrive as bubbling CustomEvents: ui-accordion:value-change and ui-accordion:focus-change, with Zag's details object as event.detail.
Off by default. A closed panel gets Zag's hidden, which keeps it out of the tab order and out of the accessibility tree, and there is no animation.
Add presence to the root. The panel is three boxes, and the middle one is easy to get wrong:
<ui-accordion presence>
<ui-accordion-item value="a">
<ui-accordion-item-trigger delegate><button>…</button></ui-accordion-item-trigger>
<ui-accordion-item-content class="panel">
<div> <!-- the grid row: no padding, border or margin -->
<div class="p-4">…</div> <!-- spacing goes here -->
</div>
</ui-accordion-item-content>
</ui-accordion-item>
</ui-accordion>The grid row must be bare. min-height: 0 lets a grid item's content box reach zero, but padding, border and margin always count toward the track's minimum size. A row carrying its own padding has a floor equal to that padding, so the close eases down, stalls there, and only hidden finishes it, which reads as a snap.
/* `:not([hidden])` matters. `[hidden]` is a user agent rule, so a bare
`display: grid` beats it and leaves a closed panel visible and tabbable. */
ui-accordion-item-content.panel:not([hidden]) {
display: grid;
overflow: hidden;
}
/* the grid row */
ui-accordion-item-content.panel > * {
min-height: 0;
}
ui-accordion-item-content.panel[data-state="open"] {
animation: accordion-open 200ms ease-out;
}
ui-accordion-item-content.panel[data-state="closed"] {
animation: accordion-close 200ms ease-out;
}
@keyframes accordion-open {
from { grid-template-rows: 0fr }
to { grid-template-rows: 1fr }
}
@keyframes accordion-close {
from { grid-template-rows: 1fr }
to { grid-template-rows: 0fr }
}Give the open and close keyframes different names. Zag compares the running animation name with the previous one to decide whether a real exit animation started. Reusing one name for both makes it unmount immediately.
It must be an animation, not a transition. @zag-js/presence decides how long to keep a closing panel mounted by reading animation-name and waiting for animationend. A transition leaves animation-name at none, so the panel unmounts on the next frame and snaps shut.
presence is opt in because it costs a second state machine per panel, and because an animation whose animationend never arrives holds the panel unhidden, and therefore tabbable, until it does.
Load it in the document head, not from JavaScript. It carries display defaults for custom elements, which the browser renders inline otherwise, and :not(:defined) rules that stop every panel painting open before the elements upgrade. A stylesheet adopted on import arrives after that paint.
Everything in it sits in an @layer ui, so it loses to any unlayered CSS you write and to any Tailwind utility. Load it before your own stylesheet. The browser orders layers by first appearance, and that ordering is what lets class="flex" on a ui-accordion beat the display: block default.
/* your stylesheet */
@import "@bagistoplus/ui/ui.css";
@import "tailwindcss";The elements register as ui-accordion and friends. Custom element names are a global registry, so if something else on your page already owns ui-accordion, this package warns in the console and does not install. Nothing inside the package depends on the names, so renaming them is a change at one line in each component's index.ts.
npm install
npx playwright install chromium
npm run dev # the documentation site, with live demos
npm test # Vitest in real Chromium
npm run typecheck
npm run docs:build # the site
If you already have Chrome installed and would rather skip Playwright's 180MB
Chromium download, run UI_BROWSER_CHANNEL=chrome npm test. CI uses the pinned
Chromium.
npm run dev serves the docs under docs/, which is the fastest way to see behavior: every component page carries live demos wired to the source, so an edit in src/ reloads them. The demos are the same markup the page documents, so a demo that breaks is a documentation bug.
Tests run in real Chromium rather than jsdom. The value of this package is focus, keyboard and ARIA, and an assertion that a collapsed panel is not tabbable proves nothing in a fake DOM.
Design decisions and their costs are recorded in docs/adr, which is excluded from the built site.