The rules that most affect how components in this library are structured and styled.
public/ui/ is the shared, app-agnostic layer. Its code and documentation must remain usable when copied into another application.
Nothing in public/ui/, including source comments and documentation, may reference a markdown file. References to application docs, repository style guides, or plugin guides would break when the library is copied elsewhere.
Keep explanations on the relevant page in this documentation tree and link to them from the code, for example /ui/docs/ui-menu.html. The job queue page explains its worker design; this page explains event tiers below. Application-specific material belongs in public/components/ and the application's documentation.
Enforced by a repo-wide check (test/z-style.test.mjs),
which fails on any such reference it finds. @tt-about <slug> concept
markers are exempt because a slug does not reference a file path. The rules below still apply to them.
The same independence applies to vocabulary: no file in public/ui/ names a concept that
belongs to the application embedding it — not in a runtime identifier, not in a
comment explaining a CSS rule, not in a docs demo's example data, not in a test fixture.
Application-specific examples make it hard for readers to distinguish library behavior from product behavior. Application-specific runtime names can also cause conflicts: two applications on the same origin would share storage if the library gave both the same database name. The job queue therefore accepts its three names from the application.
What to write instead. Explain a CSS rule by what the element is and what
the selector cannot otherwise reach, never by which screen uses it. Give a demo the generic
version of its example — a role list is Owner / Editor /
Viewer, a tree is a table of contents, a document is a document. Where a name
genuinely has to differ per application, take it as a parameter and let the app supply it.
If those changes cannot make the code reusable, it belongs in
public/components/.
A component is one self-contained .mjs file with as few dependencies as
possible, so it can be copied into another application and used there. It may import
shared helpers from public/ui/lib/ that it genuinely needs, such as
inject_styles from UiElement.mjs, and other components it renders. It
never imports modules that exist only to hold parts of itself: splitting its styles, keyboard
handling or state into sibling files that the component always loads makes nothing lighter,
and copying the component then means finding every piece.
When a component file grows too long, simplify it inside the file. Remove
duplicated state and parallel code paths, merge symmetric cases, and move explanations of the
behaviour to the component's page in this documentation, keeping in the code only the pitfalls a
reader needs at that line and a link to the page. <ui-combobox> is an example:
its behaviour model is on its page.
Components and patterns here are used by writing plain HTML. These pages and their demos are where that markup is specified: read the page, copy the shape, and the styling and behaviour follow. The author is expected to write the markup as instructed.
Nothing in the library checks that they did. No component, stylesheet or helper validates a page's markup at runtime, warns about a missing attribute, or withholds its styling until the markup is correct. A rule this documentation states is a rule the author keeps, and a broken one is found by reading the code rather than by the library refusing to work. Write the documentation so the correct markup is the obvious thing to write, rather than adding a guard against the incorrect one.
A decorator is for markup too verbose to hand-write — it adds the ARIA content, it
does not police it. tree() takes a nested
<ul>/<li> and supplies a whole APG tree view: the roving tabindex,
the role="treeitem" rows, the role="group" nesting, the
expand/collapse wiring. accordion() does the
same for a disclosure region. Each replaces markup an author would otherwise repeat on
every node and get subtly wrong. That is the test: reach for a decorator where the
hand-written version would be long and repetitive, not where it would merely be
forgettable. One attribute on one control is not a case for a decorator.
ui-<ui-dialog>, <ui-menu>, <ui-icon>.x-<x-select> (fills the gap until <select> customization ships across browsers).app-public/components/, not in the library. Example: <app-settings>.Hierarchy: sub-components extend the parent's tag with an appended segment: app-settings → app-settings-profile, app-settings-billing.
Context-role words (dialog, panel, card, sheet, popover, menu) don't belong in tag names. The same component should be placeable in any of those contexts; the host carries the role.
.mjs defines a single custom-element class.ui-dialog.mjs exports UiDialog.snake_case for functions, methods, and variables — not camelCase.Underscore-prefixed custom properties declare component-internal defaults that outer context can override. They are how a component gets variants without a variant API.
Published hooks vs. private internals. Underscore-prefixed props
(--_accent) are component internals — overridable, but not a stable API.
When a component is meant to be re-styled by consumers, expose a small set of
un-prefixed, documented hooks named after the component
(--switch-track, --switch-track-on, --switch-size, …).
Resolve them through the private vars — don't declare the public name on the
element. Map each into a private var whose var() fallback is the default
(--_track-on: var(--switch-track-on, var(--brand))) and use the private var
internally. Declaring --switch-track-on: var(--brand) directly on the element
would shadow any value an ancestor sets, silently breaking the override; the
indirection lets a value set in any scope (including an inline style on a wrapping
<label>) inherit down and take effect, while defaults still re-theme with the
app (dark mode included). See Switch · Theming.
Components use --surface-*, --text-*, --brand, --border-color — not
--stone-4, --indigo-6. This keeps dark-mode overrides in one place
(/index.css) rather than sprinkled across every component.
Style via element / contextual selectors. Use --_ private props for variants. The only utility
classes in the codebase are the small set in
/open-props/extra/utilities.css —
see Utilities.
For elements with named variants, Modifiers
lists the supported names and explains whether to use a class, an ARIA or
native attribute, a data-* value, a bare marker, or a private property without a named variant.
color-mixCompose translucent colors in OKLCH — not RGB hex + opacity or /10 suffixes.
Prefer light DOM with semantic HTML. Shadow DOM is only for reusable widgets that must not leak styles — which, in this codebase, is almost never the right answer.
Components normally receive text through the page's markup. When a component must supply its own text, use a catalog key in the form ui.<component>.<what> with an English literal as fallback. Examples include the message required by ElementInternals.setValidity() and the accessible name of a control created by the component. Do not require each page to supply these internal labels through an attribute: the component knows when they are needed and must support the active language.
Use a normal import for lib/l10n.mjs — import {t} from '../lib/l10n.mjs'.
It loads its catalog without a top-level await, so importing it does not suspend your
module and your customElements.define still runs before the first paint.
Imported component dependencies must not use top-level await: such an await
suspends every importing module and delays its define until after first paint. When such an await was added to <x-select>'s import chain, 51 WPT subtests failed because
customElements.get('x-select') was undefined. Between first paint and catalog readiness, t() returns the raw key. Arrange a re-render through l10n_register_listener(this) if the element renders text. For a string needed only later, such as a validation message, supply the English literal as a fallback.
Check whether the returned value equals the key: l10n.mjs returns the raw key for an
unknown one, so without it a missing catalog entry would render as
ui.select.value_missing in the UI.
Several components copy the host's label to an inner element that the page cannot access — <ui-menu> onto its picker or auto-created trigger,
<ui-dialog> onto its inner <dialog> when there is no title
to point at, <ui-statusbar> and <ui-notifications> onto
the buttons that open their lists. A component that copies a name must observe the
attribute it copies from and re-copy it, because the host's value routinely changes
after the element has upgraded and the copy would otherwise keep the stale one.
Which attribute is being copied does not change the rule. The first two take
the host's aria-label; the last two take a label attribute of their
own, because the name they need is for an inner button rather than for the host. Both are a
name the page wrote and the component re-published somewhere the page cannot address, so both
have to be observed.
A delayed translation can otherwise leave screen-reader labels in the wrong language.
apply_static_i18n() awaits the catalog fetch, so a page that does everything the
language policy asks — English literal in the markup,
data-i18n-attr="aria-label:…" pointing at a key present in both locales — can still expose an English screen-reader label while the visible UI is Swedish. Measured on
<ui-menu> in Chromium and Firefox: host Redigeringsmeny,
picker Editing menu.
Observe the attribute; do not subscribe to locale changes for this.
l10n_register_listener fixes only the translated case, and a live language switch
is not a case that arises — the app reloads the page on language-change. Observing
catches every late relabel and adds no dependency. And a name the page supplied — a
<button> child with its own aria-label — is the page's, never
overwritten.
Define each component delay as an underscore-prefixed static property, rather than a module-level const. This includes search debouncing, submenu hover delays, and fetch timeouts. Tests and subclasses can then adjust the delay without guessing how long to sleep. In the combobox suite, making three delays adjustable reduced elapsed time from 32.8 s to 1.5 s; tests still wait for the work to complete.
Static properties and attributes serve different scopes. The
underscore static is class-wide, aimed at tests and subclasses, and is not published API: one assignment changes the delay for every instance in a suite. An attribute is added on top only when a
page has a real reason to vary the delay per element — <ui-combobox debounce-ms="400">
for a slower search on one field. Add an attribute only when a page needs per-instance control; every public option requires ongoing support.
For a lib/ module, expose timing through an exported object, such as export const animation_timing = { grace_ms: 100 }, and read animation_timing.grace_ms. Consumers can change the object's properties; they cannot reassign an imported const binding.
After setting a delay to zero, wait for the work to complete through event-loop turns. Do not replace a long sleep with a shorter one: that still depends on machine speed. If a test verifies the delay itself, keep the real delay and wait for it.
Like underscore-prefixed CSS properties, these timing properties are accessible to consumers but are not part of the stable public API.
Don't guard against things that should never happen. If a component always lives inside a
<ui-dialog>, call this.closest('ui-dialog').close() without optional chaining —
if the parent is missing, that's a bug worth surfacing, not hiding.
Guards are appropriate for genuinely optional data (e.g. user?.roles), event-delegation filtering, and conditionally rendered elements.
UiElement.mjs)
A small set of utilities used by every ui-* / x-* component file.
Source: /ui/lib/UiElement.mjs.
| Export | Signature | Description |
|---|---|---|
html |
(strings, …values) => string |
Tagged template for multi-line HTML strings (String.raw-equivalent). Triggers HTML syntax highlighting in editors that recognize the tag. |
css |
(strings, …values) => string |
Tagged template for static _css strings on component classes. Pairs with inject_styles. |
inject_styles |
(klass, root, tag) => void |
Inject a component's static _css once per root (document.head by default), guarded by a data-…-styles marker so multiple instances don't duplicate the rule. |
placement_map |
constant object | Maps popover placement keywords ('top-start', 'bottom-end', …) to CSS position-area values. Used by anchor-positioning popovers/menus. |
A backtick inside a comment in one of these templates ends the template.
A template literal has no comment context, so a /* … */ in a css
block and an <!-- … --> in an html one are text, not
comments: the first backtick closes the string, and the prose after it re-parses as
JavaScript. Quote a code word with ", or write it plain.
The error may not mention an unterminated string. npm run lint reports
Parsing error: Unexpected token … at a word in the comment, which may be some distance from the stray backtick.
npm test stops before any suite loads and names the file: a syntax check
(scripts/check-syntax.mjs) parses every module first, and reports the offending word with a caret. A run that skips that pre-flight — a scoped
node --test, or the browser suite — can produce many downstream failures because much of the application imports this layer: one stray
backtick in toast.mjs caused 77 failures and 39 cancellations, making the original file reference difficult to find in the output. In a shared working tree, the syntax check also helps other sessions identify the source of the failure.
Multi-line HTML strings should always use the html tag rather than '…' + '…' concatenation
— backticks read as actual HTML and diff cleanly.
inject_styles prepends, so ui.css wins every specificity tie.
A component's sheet goes in ahead of the page's stylesheet links, which means a component rule
only overrides ui.css by being more specific — at equal weight, source order
decides and ui.css is later. Two rules follow: a component that needs to
override a shared rule must out-specify it rather than merely restate it, and a base element rule in
ui.css must be kept at its lowest workable specificity, since raising it silently
disables the component overrides that were sitting just above it. Wrap a qualifier in
:where() to broaden a selector without increasing specificity — input:where(:not([type="radio"]))
stays at (0,0,1), while the bare :not() would be (0,1,1),
because :not() takes its most specific argument's weight.
Choose the narrowest of these four event mechanisms that reaches every consumer.
extends EventTargetaddEventListener on it. The default.EventTargetdocument eventdocument.dispatchEvent(new CustomEvent(…)), for "something changed, everyone
re-read" across subtrees that share no state-class instance. Overuse makes the graph hard to
trace.obj.on_data = e => …. Only where per-event dispatch overhead is measurable
(≥15 Hz, render loops).
Storing a value and announcing it are two steps — plain assignment, then
this._emit('x-change'). Combining them in a setter would hide the event dispatch. Every subscription added in connectedCallback needs its removal in
disconnectedCallback.
--_ pattern.