Skip to content

Behaviors & custom elements

Relements is HTML-first: the foundation is semantic markup plus CSS, and JavaScript is optional. When a component needs interaction beyond what native HTML and CSS provide, you opt into one of two JS layers — an enhance* behavior or a <re-*> custom element. Both progressively enhance markup that already works without them; neither is required for base functionality.

Every behavior follows the same contract:

  • A single function, enhance*(root = document), that scans root for its data-re-* hosts and wires them.
  • It returns a controller — { destroy() } — that removes every listener it added.
  • It is idempotent: re-running it over an already-enhanced subtree is a no-op, guarded per host (typically a data-re-*-ready marker).
  • It is a tree-shakable named ESM export, available both from the root and from a per-behavior subpath (e.g. @relements/core/behaviors/tabs), so you only ship the behaviors you import.
  • It is fully typed (each module ships a .d.ts), and root may be a Document, Element, or ShadowRoot.
  • Where it emits events, they are bubbling CustomEvents prefixed re-* (e.g. re-change, re-select, re-dismiss), so any framework can listen with addEventListener.
import { enhanceTabs } from "@relements/core/behaviors/tabs";
// Wire every [data-re-tabs] host under document.
const controller = enhanceTabs(document);
document.addEventListener("re-change", (e) => {
console.log(e.detail); // { tabId, panelId }
});
// Later — remove all listeners this call added.
controller.destroy();

Passing a narrower root scopes the enhancement; this is how framework components enhance just their own subtree on mount and destroy() on unmount:

const controller = enhanceTabs(myComponentRoot);
// onUnmount → controller.destroy();

Progressive enhancement, not a requirement

Section titled “Progressive enhancement, not a requirement”

Behaviors never own the markup — they layer over HTML that already works. The native baseline keeps working with zero JavaScript:

  • A <dialog> opens, closes, and traps focus natively; enhanceDialog only adds ergonomics like a trigger button and backdrop-click dismissal.
  • A <textarea data-autosize> is a normal resizable textarea (and modern browsers grow it in pure CSS via field-sizing: content); enhanceAutosize is a no-op fallback for engines without it.
  • <re-popover> / enhancePopover feature-detect the Popover API and bail out gracefully where it is absent (see Browser support).
  • A combobox stays a native <input list> + <datalist>; the password toggle button is authored hidden and only un-hidden by JS, so no dead control is ever shown.

Because behaviors find their hosts by data-re-* attributes rather than by imperative wiring, a single global init can enhance a whole page with no per-page JavaScript — you just author the right attributes. The documentation site does exactly this: its client init (src/client/enhance.ts) imports the behaviors once and runs each over document, so any demo that is written declaratively becomes interactive on the site without an inline script.

<!-- No page script needed — a global enhanceDialog wires this. -->
<button type="button" data-re-dialog-trigger data-re-dialog-target="confirm">Open</button>
<dialog id="confirm" data-re-dialog-close-on-backdrop>
<button data-re-dialog-close value="cancel">Cancel</button>
</dialog>

Genuinely imperative APIs have no declarative form. showToast is a function you call, not markup you author, so the docs site wires demo buttons with a docs-only hook and shows the real showToast(...) call alongside.

All 20 behaviors are exported by name from @relements/core and from @relements/core/behaviors/<name>.

  • enhanceDialog — ergonomics for native <dialog>: data-re-dialog-trigger/-target to open, data-re-dialog-close buttons, optional backdrop-click and data-re-dialog-no-dismiss. Native showModal/Escape/focus stay native. See Dialog, Alert dialog, Drawer.
  • enhanceMenuButton — the ARIA menu-button pattern (toggle, roving focus, typeahead, outside-click close); emits re-select ({ item, value }). See Menu button.
  • enhancePopover — anchored positioning for native [popover] and a re-toggle event mirroring the native toggle. No-ops without the Popover API. See Popover.
  • enhanceContextMenu — opens a styled role="menu" at the pointer on right-click (and ContextMenu key / Shift+F10); emits re-select. Falls back to the native browser menu with no JS. See Context menu.
  • enhanceCommandPalette — turns a <dialog> into a filterable command launcher (combobox/listbox ARIA, type-to-filter, optional hotkey); emits re-command. Reuses enhanceDialog for the modal lifecycle. See Command palette.
  • enhanceTabs — the ARIA tabs pattern with automatic activation and Arrow/Home/End keys; emits re-change ({ tabId, panelId }). See Tabs.
  • enhanceToolbar — collapses a role="toolbar" to one Tab stop with Arrow-key roving; composes with a hosted menu. See Toolbar.
  • enhanceCarousel — back-fills prev/next + a dot strip over a native CSS scroll-snap track (.re-carousel), tracks the active slide by box geometry (RTL-safe), inerts off-screen slides, and announces the settled slide. Optional data-re-carousel-autoplay adds an auto-advance timer with a Pause/Play button (WCAG 2.2.2), pausing on hover/focus/hidden-tab and starting paused under reduced motion. No custom event — derive the index from native scroll. See Carousel.
  • enhanceCombobox — a styled role="listbox" over a native <input list> + <datalist>; case-insensitive filtering and the ARIA editable-combobox pattern. Commits dispatch native input/change. See Combobox.
  • enhanceNumberStepper — wires large +/− buttons to a native number input (native stepUp/stepDown, then input/change). See Number stepper.
  • enhancePasswordToggle — a show/hide button that flips a password field’s type and reflects aria-pressed; preserves caret. See Password toggle.
  • enhanceAutosize — grows a .re-textarea[data-autosize] with its content; a no-op where CSS field-sizing: content is supported. See Autosize textarea.
  • enhanceOtp — autofill-safe polish for a one-time-code input (active-cell hook, optional digit-strip); never splits the field. See OTP.
  • enhanceTagsInput — turns a text input into a token/chip editor that submits an array; emits re-tags-change ({ values }) + change. See Tags input.
  • enhanceRating — normalizes arrow-key direction in a star-rating radio group across browsers. See Rating.
  • enhanceRange — turns two overlaid range inputs into a two-thumb min–max slider; no custom events (native input/change bubble). See Range.
  • enhanceFilePicker — echoes the filenames, wires drag-and-drop, clear, and accept/size/count validation over a .re-file-picker; the native input stays the form value. Emits re-error on a rejected drop (value changes use native change). See File picker.
  • enhanceMultiSelect — over a .re-multiselect (<details> of checkboxes): writes the live “N selected” summary, closes on Escape / outside-click, and enforces data-re-multiselect-required (blocks submit, aria-invalid on the <fieldset>, reveals the message). The checkboxes stay the form value; value changes use native change. See Multi-select.
  • enhanceDismissible[data-re-dismiss] buttons hide their [data-re-dismissible] ancestor; emits cancelable re-dismiss. See Banner, Alert.
  • showToast(message, options) — imperative; appends a toast to a [data-re-toast-region] (creating one on document.body if absent) and returns { dismiss, element }. Not an enhance* function — there is no markup to wire. See Toast.

Six <re-*> custom elements wrap the behaviors above for consumers who prefer a tag over an imperative call. They are:

  • Light-DOM only — no Shadow DOM. Your markup stays in the light tree, so component CSS, page styles, forms, and querySelector all work normally.
  • Self-registering on import — importing the module calls customElements.define() as a side effect. Import each element you use; package.json lists elements/*.js under sideEffects so bare imports survive tree-shaking (see HTML-first policy).
  • The same class/attribute/event contract — on connect, each host applies its own .re-* class and data-re-* marker, then runs its behavior over itself and tears down on disconnect. The element re-dispatches the behavior’s re-* events, so the HTML and the custom-element APIs converge.
import "@relements/core/elements/re-tabs";
ElementWrapsExposes
<re-tabs>enhanceTabsvalue property (selected tab id; set to switch); re-dispatches re-change. Observes children so frameworks that project late still enhance.
<re-menu>enhanceMenuButtonopen boolean property; re-dispatches re-select.
<re-popover>enhancePopovershow() / hide() / toggle() methods; open property (reflects :popover-open). Adds the native popover attribute on connect.
<re-toast>showToast.show(message, options) method scoped to its own region; materializes a .re-toast-region on connect.
<re-file-picker>enhanceFilePickerfiles property (read/write the FileList) and clear() method; re-dispatches re-error when files are rejected.
<re-tags-input>enhanceTagsInputvalues property (read-only string[]) and clear() method; the element IS the group (container mode), so re-tags-change fires on it.

Behavior vs custom element — which to reach for

Section titled “Behavior vs custom element — which to reach for”

Both wire the same logic; pick by integration style.

Reach for a behavior when you want to enhance existing markup imperatively and control the lifecycle yourself — a global page init, a framework component enhancing its own subtree on mount and calling destroy() on unmount, or scoping enhancement to a ShadowRoot. Behaviors are also the only option for most form inputs and the dismissal helpers, which have no custom-element wrapper.

Reach for a custom element when you want a declarative, self-managing tag — drop <re-tabs> into any template and it enhances and cleans itself up via connectedCallback/disconnectedCallback, no init call to remember. It is the natural fit for plain HTML pages and for frameworks that render custom elements directly. The six elements wrap tabs, menu, popover, toast, the file picker, and the tags input; everything else is behavior-only.

Either way the underlying markup is the same semantic HTML, so you can start with one and switch later without changing the document’s structure.

  • HTML-first policy — why JS is an optional layer.
  • Browser support — what degrades gracefully where a platform feature is missing.
  • Versioningre-* events, data-re-* hooks, and <re-*> tags are public API.