Proper UI

Custom elements

@properui/elements: Proper UI as light-DOM custom elements for Vue, Angular, Svelte, Astro and plain HTML. Every tag, attribute, event and slot.

@properui/elements is Proper UI as framework-agnostic custom elements: <pui-button>, <pui-modal>, <pui-tabs> and the rest of the set below. They work the same in Vue, Angular, Svelte, Astro, plain HTML and vanilla JS, render the @properui/html markup, and call its behaviours for keyboard and ARIA wiring. No Lit, no framework runtime, no dependencies beyond @properui/html.

React is still the primary, fullest layer: 126 component groups built on React Aria. The elements cover a curated set of 21 tags, with accessibility from native HTML (<button>, <dialog>, real form controls) plus the roles and aria-* attributes the behaviours set. See Frameworks for the support matrix.

Install

npm install @properui/elements @properui/tokens
// tokens + component classes, prebuilt
import "@properui/elements/register";
import "@properui/tokens/properui.css";

// defines every <pui-*> element

In a Tailwind v4 project, import the source CSS instead of the prebuilt file, so utilities and the component classes share one build:

@import "tailwindcss";
@import "@properui/tokens/theme.css";
@import "@properui/html/css";

No build step: one stylesheet, one script. The script bundles the @properui/html behaviours and exposes window.ProperUIElements.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@properui/tokens/dist/properui.min.css" />
<script src="https://cdn.jsdelivr.net/npm/@properui/elements/dist/properui-elements.global.js" defer></script>

register calls defineElements(). Import the package root instead to define a subset, or to reach the classes:

import { defineElements, setTheme, toast } from "@properui/elements";

defineElements(["pui-button", "pui-modal"]);

defineElements skips a tag that is already defined and does nothing during server rendering, so importing it from shared code in Nuxt, SvelteKit, Astro or Next is safe.

How the elements behave

  • Light DOM. There is no shadow root. Each element renders its markup as its own children, so the global stylesheet styles it, <form> sees the controls, and document.querySelector reaches inside.
  • Your children are the content. They are moved, not copied, into the rendered markup: the text of a <pui-button> ends up inside its <button>. A framework that owns those nodes keeps updating them (a Vue {{ label }} binding, a Svelte {#if} block); appendChild, insertBefore, removeChild and textContent on the element are forwarded to wherever the content now lives.
  • Named slots use the slot attribute on a child: <span slot="icon-leading">. There is no <slot> element; the child is moved into place.
  • Attributes mirror the React props, kebab-cased: color, size, is-disabled, is-loading. Every attribute also has a camelCase property (el.isDisabled = true) that reflects to the attribute. A boolean attribute is true when present, except the string "false", which is what Vue and Angular attribute bindings write for false.
  • Rendering happens once, on first connection. Moving an element does not render it again. Changing an attribute updates the markup in place: the same <button> or <input> stays, so focus is kept.
  • Form controls are form-associated. pui-input, pui-textarea, pui-select, pui-checkbox and pui-toggle use ElementInternals where the browser supports it: the element submits under its own name, validates, resets and follows <fieldset disabled>. Where it is not supported, the inner native control carries the name instead. input and change are re-dispatched from the element itself, so event.target.value is always current.
  • Custom events bubble, are composed, and carry their data in detail.
  • A small <style data-pui-elements> is added to <head> once, with zero-specificity display defaults for the elements that wrap a control (display: contents for pui-button, block for pui-input, ...).

Framework notes

Vue 3 / Nuxt. Tell the compiler these are custom elements, and add the typings for template checking:

// vite.config.ts
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("pui-") } } });
// env.d.ts
/// <reference types="@properui/elements/vue" />

v-model works on pui-input, pui-textarea and pui-select (it sets .value and listens to input). Listen to custom events with @pui-close, @pui-page-change, and so on.

Angular. Add CUSTOM_ELEMENTS_SCHEMA to the standalone component's schemas. Bind boolean attributes with [attr.is-disabled]="busy ? '' : null", events with (pui-close)="...".

Svelte 5 / SvelteKit. No configuration. Custom events are onpui-close={...} attributes; import the register module in onMount or a +layout.svelte script (it is a no-op on the server anyway).

Astro. Write the tags in .astro markup; add <script>import "@properui/elements/register";</script> once. The tags render as plain HTML on the server and upgrade in the browser.

React / Next.js. Use @properui/ui: that is the full, React Aria based layer. For markup shared with other stacks (or a Server Component that should ship no React Aria), /// <reference types="@properui/elements/react" /> adds the tags to JSX.IntrinsicElements; define them from a "use client" module that imports @properui/elements/register. React 19 passes onpui-close={...} to addEventListener.

Elements

pui-button

A <button class="pui-btn">, or an <a> when href is set.

AttributeValuesDefault
colorprimary, secondary, tertiary, link, link-color, link-gray, link-destructive, primary-destructive, secondary-destructive, tertiary-destructiveprimary
sizesm, md, lg, xlsm
hrefURL: renders an <a>
target, relPassed to the <a> (rel="noopener noreferrer" is added for target="_blank")
typebutton, submit, resetbutton
name, value, formPassed to the <button>
labelAccessible name; required when the button has only an icon (also its hover title)
is-disabledBoolean. A disabled link loses its href and gets aria-disabled="true"
is-loadingBoolean. Spinner, aria-busy="true", disabled

Slots: icon-leading, icon-trailing (a button with only an icon gets pui-btn--icon-only). Properties: control (the rendered <button>/<a>); focus() and click() go to it.

pui-badge

The element itself is the .pui-badge.

AttributeValuesDefault
colorgray, brand, error, warning, success, blue, indigo, purple, pink, orangegray
sizesm, md, lgmd
typepill-color, modernpill-color
dotBoolean: a leading status dot

pui-avatar

The element itself is the .pui-avatar.

AttributeValuesDefault
srcImage URL
altImage description; also the name of an initials avatar
initialsShown when there is no src or it fails to load
sizexs, sm, md, lg, xl, 2xlmd
statusonline, offline

pui-input

A .pui-field with a label, an <input class="pui-input">, and a hint or an error.

AttributeValuesDefault
labelLabel text
hintHelp text under the input (aria-describedby)
errorError text: sets aria-invalid, replaces the hint, and a custom validity message
sizesm, md, lgmd
typeAny text-like input typetext
name, value, placeholder, autocompleteAs on <input>; value is the default value
required (or is-required)Boolean
is-disabled (or disabled), is-read-onlyBoolean
min, max, step, minlength, maxlength, pattern, inputmodeAs on <input>

Events: input, change (from the element). Properties: value, form, validity, validationMessage; checkValidity(), reportValidity(), focus().

pui-textarea

pui-input's attributes, events and properties on a <textarea class="pui-textarea">, plus rows (default 4).

pui-select

A labelled native <select> inside .pui-select. Its <option> and <optgroup> children become the options; value selects one. Same label, hint, error, size, name, required, is-disabled attributes and the same events and properties as pui-input.

pui-checkbox and pui-toggle

A <label class="pui-checkbox"> (or .pui-toggle, with role="switch") around a real checkbox.

AttributeValuesDefault
labelLabel text; the element's content when absent
hintHelp text under the label
checked (or is-selected)Boolean: the initial state
is-indeterminateBoolean (checkbox)
name, valueSubmitted when checkedon
sizesm, mdsm
is-disabled (or disabled), requiredBoolean

Events: input, change. Properties: checked, value, form, validity.

pui-alert

The element itself is the .pui-alert.

AttributeValuesDefault
variantinfo, success, warning, error (color with a React Alert colour also works)info
titleHeading (moved off the element so it is not also a hover tooltip)
dismissibleBoolean: a close button
dismiss-labelThe close button's nameDismiss

Content is the body. Slot: actions. Error and warning alerts get role="alert", the others role="status" (set role yourself to change it). Event: pui-dismiss (cancelable; unless cancelled the alert sets hidden). Method: dismiss().

pui-tabs, pui-tab, pui-tab-panel

pui-tabs is the .pui-tabs root that initTabs drives: roles, a roving tabindex, the arrow keys (mirrored in RTL), Home and End, and automatic activation.

ElementAttributeValues
pui-tabslabelThe tab list's accessible name
typeunderline (default), button
selectedA tab's value, or its index
pui-tabvaluePairs it with the panel of the same value (else by order)
is-disabledBoolean
pui-tab-panelvalueSee pui-tab

Event: pui-change with { value, index } on pui-tabs. The set of tabs is read on first render; to change which tabs exist, render a new pui-tabs (a :key in Vue, {#key} in Svelte).

pui-dropdown and pui-menu-item

pui-dropdown is the .pui-dropdown root that initDropdowns drives: open and close, arrow keys, Home/End, type-ahead, Escape, outside click, role="menu".

ElementAttributeValuesDefault
pui-dropdownlabelTrigger text (and its name when the trigger slot has no text)Options
colorTrigger colour, as pui-buttonsecondary
sizeTrigger sizesm
alignstart opens the menu from the start edgeend
pui-menu-itemvalueReported by pui-select (the text when absent)
hrefRenders the item as a link
shortcutTrailing hint, such as ⌘K
is-disabledBoolean

Children of pui-dropdown: pui-menu-items and <hr> separators. Slot: trigger replaces the trigger's content. pui-menu-item slot: icon. Event: pui-select with { value, item } on pui-dropdown.

pui-modal

Wraps a native <dialog class="pui-modal">: focus containment, Escape, the top layer and the inert background come from the platform.

AttributeValuesDefault
openBoolean: shown as a modal; removed again when it closes
titleHeading (aria-labelledby)
descriptionSupporting text under the title
labelAccessible name when there is no title
sizesm, md, lgmd
is-dismissable"false" keeps it open on backdrop click and Escape
close-labelThe close button's nameClose

Content is the body. Slots: footer (the actions), icon (a featured icon above the title). Methods: show(), close(returnValue?). Property: dialog. Event: pui-close with { returnValue }, however it closed (close button, Escape, backdrop, <form method="dialog">). Any element with data-pui-modal-open="<id of the pui-modal>" opens it.

pui-tooltip

Wraps one trigger and describes its first focusable element (the rendered <button> of a pui-button inside it) through initTooltips: hover after a delay, keyboard focus, Escape, role="tooltip", aria-describedby.

AttributeValuesDefault
textTooltip text
descriptionA second line
placementtop, bottom (flips when there is no room)top
delayHover delay in ms300

A tooltip supplements a label; it is never the only name of a control.

pui-progress

AttributeValuesDefault
valueNumber0
maxNumber100
labelAccessible name of the progress barProgress
sizesm, mdmd
show-valueBoolean: prints the percentage

pui-skeleton

variant (text, circle, rect; default text), width, height (CSS lengths). Always aria-hidden; put aria-busy="true" on the region that is loading.

pui-breadcrumbs

Each child element (an <a>, or a <span> for the current page) becomes a crumb; the last one gets aria-current="page". label names the landmark (default Breadcrumb).

pui-pagination

AttributeValuesDefault
pageCurrent page, 1-based1
totalNumber of pages1
labelLandmark namePagination
previous-label, next-labelButton textPrevious, Next

Event: pui-page-change with { page, previous } (cancelable; unless cancelled page updates). Method: goTo(page).

pui-theme-toggle

An icon button that switches .dark-mode on <html> through setTheme (saved in localStorage under theme, the key the React ThemeProvider uses), and follows theme changes made elsewhere. size; label-light and label-dark for its accessible names. Event: pui-theme-change with { theme }.

Toasts

Not an element: call toast({ title, description?, variant?, duration? }) from @properui/elements (or ProperUIElements.toast(...) with the script build). Toasts appear in a polite live region.

Events

ElementEventdetail
form controlsinput, change(native Event)
pui-alertpui-dismissnone, cancelable
pui-tabspui-change{ value, index }
pui-dropdownpui-select{ value, item }
pui-modalpui-close{ returnValue }
pui-paginationpui-page-change{ page, previous }, cancelable
pui-theme-togglepui-theme-change{ theme }

What is React-only

Everything outside the 21 tags above: date pickers, comboboxes, sliders, data tables, charts, command menus, file upload, the application shells, every marketing section and page example. The elements also do not reproduce React Aria's behaviour line for line (press events with pointer normalisation, typeahead in selects, virtual focus). The Frameworks page lists the React-only groups by name.