Most primitives are opt-in CSS recipes copied into the application. Avatar is
an autonomous leaf custom element whose copied module registers only
mado-ui-avatar. Combobox, Menu, Tabs and Tooltip use small copied light-DOM
helpers where the platform does not yet supply their complete behavior. None
of the primitives require a shared Mado UI browser runtime.
Use a native button for actions and an anchor with href for navigation:
<button class="mado-ui-button" type="submit" name="intent" value="save">
Save
</button>
<button
class="mado-ui-button"
type="button"
data-variant="secondary"
>
Cancel
</button>
<a class="mado-ui-button" data-variant="ghost" href="/docs">Read docs</a>
<a class="mado-ui-link" href="/changelog">Changelog</a>data-variant accepts secondary, ghost and danger; no attribute is the
primary variant. data-size accepts small and large. data-block makes
the button fill its container.
Always write the button type explicitly. Only native <button disabled> is
supported as disabled: aria-disabled on an anchor does not prevent clicks or
keyboard navigation.
.mado-ui-control is for native text-like inputs, select and textarea.
Checkboxes retain native appearance and use .mado-ui-checkbox.
<label for="project-name">Project name</label>
<input
id="project-name"
class="mado-ui-control"
name="project"
required
/>
<label class="mado-ui-check">
<input class="mado-ui-checkbox" name="updates" type="checkbox" />
<span>
Send release notes
<small class="mado-ui-check-description">
One message after every release.
</small>
</span>
</label>Use native required, disabled, readonly, name and autocomplete.
Expose application-owned invalid state on the control with
aria-invalid="true". The recipe deliberately does not style the native
:invalid pseudo-class, so an untouched required control is not painted as
an error before the application's validation lifecycle begins.
Date, time, local date-time, month and week inputs keep their native picker and intrinsic controls. Their inline padding is left to the browser so current iOS WebKit cannot add authored padding outside the field's available width.
Use the independent field block for
label, description, validation-message and fieldset composition. It can wrap
these native controls or a different application-owned control without
coupling its structure to this recipe.
Use combobox when a user may type an arbitrary value but benefits from a
short, local set of suggestions. If a value must be one of the available
options, use a native select instead.
The static contract is a native input[list] and datalist. The toggle and
empty listbox are authored hidden, so an unsupported browser never exposes
half of a custom widget. This keeps the
HTML datalist relationship
as the baseline:
import { html, page } from "@madojs/mado";
import { madoUiCombobox } from "./components/mado-ui-combobox.js";
export default page({
title: "Assign reviewer",
view: () => {
const reviewer = madoUiCombobox();
return html`
<label for="reviewer">Reviewer</label>
<div class="mado-ui-combobox" ref=${reviewer.root}>
<input
id="reviewer"
class="mado-ui-control mado-ui-combobox-input"
name="reviewer"
type="text"
list="reviewer-options"
autocomplete="off"
required
/>
<button
class="mado-ui-combobox-toggle"
type="button"
tabindex="-1"
popovertarget="reviewer-listbox"
aria-label="Show reviewer suggestions"
hidden
>⌄</button>
<datalist id="reviewer-options">
<option value="Ada Lovelace" label="Platform engineering"></option>
<option value="Élodie Durand" label="Localization"></option>
<option value="Grace Hopper" label="Compiler systems"></option>
</datalist>
<ul
id="reviewer-listbox"
class="mado-ui-combobox-listbox"
popover="auto"
aria-label="Reviewer suggestions"
hidden
></ul>
</div>
`;
},
});The four elements must be direct children of the root. The input must be
type="text" or type="search", have a document-unique ID, and reference the
direct datalist by ID. The button needs type="button", tabindex="-1", an
accessible name and a popovertarget matching the direct listbox. The listbox
needs its own document-unique ID, popover="auto" and an accessible name.
Empty or disabled datalist options are not copied into the enhanced list.
The helper enhances only when the Popover API is present and the complete
relationship validates. Until then the input keeps its list attribute and
the browser owns suggestions, editing, validation, reset and submission.
After a successful connection the helper:
- marks the root with
data-mado-ui-combobox-enhanced; - temporarily removes
input[list]so two suggestion popups cannot compete; - exposes the toggle and listbox;
- gives the input
role="combobox",aria-autocomplete="list",aria-controlsandaria-expanded; - gives the generated popup and suggestions
listboxandoptionsemantics.
aria-haspopup="listbox" is implicit for a combobox and is intentionally not
added. The datalist remains the authored source of truth. A mutation rebuilds
the generated options, while a malformed relationship fails open to the
native fallback and can enhance again after repair. Disposing the root ref
removes listeners and observers and restores the exact authored attributes.
Filtering is case- and diacritic-insensitive prefix matching over the option
value and label. An empty query exposes every available suggestion. Set
filterMode: "external" when the application or a remote provider has already
produced the exact option set for the current query; the helper then presents
every usable authored option without applying a second text filter. The helper
does not debounce or perform network requests; remote fetching, loading,
failure and announcement state belong to the application.
Typing records popup intent even when no option exists yet. If an asynchronous request later updates the datalist, matching suggestions open automatically while the input still owns focus. Escape, Tab, blur, light dismissal, reset, selection and a disabled or readonly transition cancel that intent, so a late response cannot unexpectedly reopen dismissed suggestions.
On narrow viewports the anchored list opens above the input when possible, flips below when necessary and scrolls inside a bounded height. It must never be treated as a centered modal surface or cover the text being edited.
DOM focus always remains on the input. The active option is exposed through
aria-activedescendant, aria-selected and nearest-edge scrolling; generated
options never join the Tab sequence. This follows the
WAI-ARIA editable combobox pattern
while leaving standard text-editing keys with the browser.
- typing, paste and the end of an IME composition filter and open suggestions;
- Down and Up open the popup and move through visible suggestions with wrapping;
- Alt+Down opens without choosing an active suggestion;
- Alt+Up and Escape close without changing or clearing the value;
- Enter accepts only the active suggestion;
- Tab closes and continues the normal document sequence.
Home, End, Left, Right, Delete, Backspace and platform editing shortcuts keep their native text-editing behavior. The helper does not intercept modified printable keys or interfere while an IME composition is active.
The toggle is deliberately outside the Tab order. Clicking it returns focus to the input and only changes popup visibility. Pointer selection keeps input focus and applies the chosen value. Popover light dismissal closes the popup without inventing a second outside-click layer.
Accepting a suggestion first dispatches a cancelable beforeinput event with
inputType="insertReplacementText". Cancellation preserves the authored
value. A successful changed selection then updates the native input and emits
input, followed by change; selecting its existing value emits neither
change event. Some browsers try to commit the same dirty text control again
when a synchronous onSelect action closes a dialog or otherwise moves
focus. The helper absorbs that matching follow-up change, so one accepted
replacement remains one observable change. New input or a form reset clears
the guard and leaves the next native commit untouched.
The input remains the sole successful form control. Keep native name,
required, disabled, readonly, autocomplete and constraint validation.
A form reset restores the browser's default value and closes the popup.
External form ownership through form="<id>" is supported. Disabled and
readonly inputs do not open or accept suggestions, and a live attribute
change is reconciled without replacing the input.
For a composed block that executes the accepted suggestion, use the optional callbacks instead of duplicating filtering or inspecting helper-owned DOM:
const command = madoUiCombobox({
onSelect: ({ option, value }) => {
runCommand(option.dataset.commandId, value);
},
onResultsChange: ({ query, visible }) => {
resultsStatus.set(
visible === 0 ? `No commands match ${query}.` : "",
);
},
});For a provider-filtered location search, keep the datalist as the reactive result projection and disable only the helper's local prefix pass:
const location = madoUiCombobox({
filterMode: "external",
onSelect: ({ option }) => selectLocation(option.dataset.locationId),
});onResultsChange reports the authored query plus visible and total usable
option counts whenever filtering changes after connection. onSelect runs
once after an uncanceled keyboard or pointer acceptance, after the popup has
closed and focus has returned to the input. It receives the original option,
so application-owned IDs stay on the datalist instead of leaking into
generated listbox markup. Canceling beforeinput also cancels this callback.
Explicitly accepting the same value again still invokes onSelect, while
native input and change remain suppressed because the value did not
change.
Install the complete source-owned primitive with:
mado-ui add comboboxUse native code, preformatted text and keyboard-input elements:
Run <code class="mado-ui-code">mado-ui doctor</code>.
<pre
class="mado-ui-code-block"
tabindex="0"
aria-label="Mado UI install example"
><code>mado-ui add button form-controls</code></pre>
Press <kbd class="mado-ui-kbd">Enter</kbd>.A code block keeps its whitespace and scrolls inside its own border when a
line is wider than the container. tabindex="0" makes that scrolling region
keyboard-accessible and activates its focus indicator. Syntax highlighting
and copy actions belong to a later composed block.
Public properties include --mado-ui-code-background,
--mado-ui-code-color, --mado-ui-code-font-family,
--mado-ui-code-block-background and --mado-ui-code-block-padding.
.mado-ui-dialog styles the browser's native dialog element without
reimplementing its top layer, modality or keyboard behavior. Use a modal
dialog only when a short, interrupting decision must be completed before the
underlying page can continue:
<button
id="discard-trigger"
class="mado-ui-button"
type="button"
>
Review unsaved changes
</button>
<dialog
id="discard-dialog"
class="mado-ui-dialog"
aria-labelledby="discard-title"
aria-describedby="discard-description"
>
<header class="mado-ui-dialog-header">
<h2 id="discard-title" class="mado-ui-dialog-title">
Discard invoice changes?
</h2>
<p
id="discard-description"
class="mado-ui-dialog-description"
>
Your unsaved invoice changes will be lost.
</p>
</header>
<form
id="discard-form"
class="mado-ui-dialog-content"
method="dialog"
>
<p>The current invoice has three edited fields.</p>
</form>
<div class="mado-ui-dialog-actions">
<button
class="mado-ui-button"
data-variant="secondary"
type="submit"
form="discard-form"
value="cancel"
autofocus
>
Keep editing
</button>
<button
class="mado-ui-button"
data-variant="danger"
type="submit"
form="discard-form"
value="discard"
>
Discard changes
</button>
</div>
</dialog>Keep the header, content and action row as the dialog's three direct visual regions. The header and actions remain visible while the content region alone scrolls. Do not wrap those regions in another layout element: that breaks the bounded three-region contract and can reintroduce nested scrolling.
The action row is deliberately not required to be a form. In this example the
middle region is the workflow form, and each action uses its native form
attribute to submit it from the sibling action row. This keeps the three regions
valid without nesting forms. A dialog that complements an existing page form
should itself remain next to that page form, not inside it, because HTML does
not allow forms to be nested.
The native
dialog element
already exposes dialog semantics. showModal() moves it into the top layer
and makes the rest of the document inert. Do not add a redundant
role="dialog", aria-modal="true", overlay element, JavaScript-managed body
scroll lock or application focus trap. Give every dialog an accessible name,
normally with aria-labelledby pointing to its visible title. Use
aria-describedby only for a short, simple message; omit it when the content
contains structures or several paragraphs that should be explored separately.
Native modality does not itself prevent wheel or touch scrolling from moving
the document behind the backdrop in every browser. While a styled dialog
matches :modal, the recipe therefore freezes the document scroll container
and reserves a stable scrollbar gutter with a zero-specificity
:root:has(.mado-ui-dialog:modal) rule. It unlocks automatically after the
last styled modal closes and never matches a closed dialog or one opened with
show(). Browsers without the relational selector retain their native
behavior; do not add a JavaScript scroll-lock fallback.
The application calls showModal() from the activating button; it never adds
or removes open manually. Before each opening, clear a stale
dialog.returnValue. The browser then remembers the invoking element and
moves focus inside the dialog.
Choose initial focus for the task. A short confirmation should normally put
autofocus on its least destructive action. A long or structurally rich
dialog may instead put both autofocus and tabindex="-1" on its title so
the beginning remains visible and readable. Use only one autofocus target.
The dialog element itself must not have a tabindex attribute.
For a modal dialog, Tab and Shift+Tab stay in its native tab sequence.
Do not add positive tabindex values or duplicate that behavior in
application code. Include a visible native button that closes the dialog.
Escape issues a close request and fires the cancelable cancel event; unless
the application prevents that event for a genuine blocking reason, the
dialog closes and then fires close.
Closing normally restores focus to the invoking element. If that element was removed or the completed workflow has a more logical next target, the application must move focus there explicitly. These choices follow the WAI-ARIA modal dialog guidance, while native HTML supplies the mechanics.
For immediate choices, a native form method="dialog" closes without a
network submission and places the activated submit button's value in
dialog.returnValue:
const trigger = document.querySelector("#discard-trigger");
const dialog = document.querySelector("#discard-dialog");
trigger.addEventListener("click", () => {
dialog.returnValue = "";
dialog.showModal();
});
dialog.addEventListener("close", () => {
if (dialog.returnValue === "discard") {
discardUnsavedChanges();
}
});Use real native submit buttons associated with that form so the submitter
value is preserved. They may be descendants of the form or use the native
form attribute from the sibling action row, as in the example.
An asynchronous operation that can fail should keep the dialog open, expose
its pending and error states inside, and call close() only after success;
method="dialog" would otherwise close too early. Use requestClose() when a
custom affordance should share the cancelable close-request lifecycle. Do not
remove open directly because that bypasses the normal close event and modal
cleanup.
The missing closedby value gives a modal dialog close-request behavior:
Escape closes it, while an outside pointer action does not. Where the newer
attribute is supported, the application may opt into native light dismissal
with closedby="any" when losing the current dialog state is harmless. Its
default safe fallback in older browsers is no light dismissal. If a product
explicitly requires the same light-dismiss policy in those browsers, the
application may implement and test a geometry-aware pointer fallback that
requires both the pointer start and activation to occur outside the dialog
rectangle. Do not use event.target === dialog alone because clicks on empty
space inside the surface have the same target. Confirmations that can discard
work should keep the non-light-dismiss default, and pending work must remain
free to veto either native or fallback close requests.
show() creates a non-modal dialog: page content remains interactive and
focus is not contained. That is a different product pattern and requires an
explicit placement and focus policy from the application. The catalog
showcase uses showModal() only.
Modal and non-modal placement also remains browser-owned. Do not replace the
user agent's fixed :modal placement with application position: relative;
if an application needs a containing block for decoration, native fixed modal
placement already provides one.
A button-driven dialog needs client-side behavior. For a critical workflow that must remain available without JavaScript, use a real link to a standalone page as the baseline and enhance that navigation into a dialog. The CSS item does not synthesize a fallback route.
The recipe keeps the dialog within the viewport and gives it exactly one local
scroll container. Header and action regions stay pinned while tall content
scrolls with contained overscroll, a stable scrollbar gutter and an inset for
focus indicators. The dialog surface itself does not scroll, and a modal keeps
the underlying document at its existing scroll position. Its modal-time stable
gutter preserves space for a classic scrollbar; applications that require an
unchanging gutter even on short or overlay-scrollbar documents can reserve one
on the root for the whole document lifecycle. Actions wrap in source order, and
no entry or exit motion is added. The example optionally composes the
independent button item; dialog itself depends only on theme:
mado-ui add button dialogPublic properties:
| Purpose | Property |
|---|---|
| Surface | --mado-ui-dialog-background, --mado-ui-dialog-border, --mado-ui-dialog-color, --mado-ui-dialog-radius, --mado-ui-dialog-shadow |
| Size | --mado-ui-dialog-inline-size, --mado-ui-dialog-max-block-size, --mado-ui-dialog-padding |
| Layout | --mado-ui-dialog-gap, --mado-ui-dialog-header-gap, --mado-ui-dialog-content-gap, --mado-ui-dialog-content-focus-gutter |
| Description | --mado-ui-dialog-description-color |
| Actions | --mado-ui-dialog-actions-gap, --mado-ui-dialog-actions-justify |
| Backdrop | --mado-ui-dialog-backdrop |
disclosure styles the browser's native
details and summary elements.
Use a standalone disclosure for supplementary content that is useful on
demand and can be hidden without preventing the task:
<details class="mado-ui-disclosure">
<summary class="mado-ui-disclosure-summary">
Can I edit the installed source?
</summary>
<div class="mado-ui-disclosure-content">
<p>Yes. The copied files belong to the application.</p>
</div>
</details>The summary must be the first child and must describe what becomes visible.
Do not put links, buttons or other interactive descendants inside it. The
browser gives the summary its disclosure semantics, focusability, expanded
state and Enter/Space activation. Keep native Tab order for the summary and
for any controls revealed inside the content. Do not add role,
aria-expanded, aria-controls, tabindex or click and key handlers that
duplicate those mechanics. Arrow, Home and End navigation are not part of
this native disclosure contract.
The open attribute is boolean by presence. open="false" is still open;
remove the attribute to close the disclosure. If application behavior needs
to react after the state changed, listen to the native toggle event. Do not
mirror the basic expanded state in application state merely to render the
same attribute.
Native activation already leaves focus on the operated summary. Before application code closes a disclosure whose content contains focus, including by opening another member of its named group, move focus to that next summary or another logical visible destination. The browser cannot infer the application's focus policy for a programmatic state change.
Several disclosures can stay independent and allow any number of answers to
remain open. Add the same non-empty name only when the product genuinely
benefits from an exclusive group:
<section aria-labelledby="project-faq-title">
<h2 id="project-faq-title">Project questions</h2>
<div class="mado-ui-disclosure-group">
<details
class="mado-ui-disclosure"
name="project-faq"
open
>
<summary class="mado-ui-disclosure-summary">
Does Mado UI ship a browser runtime?
</summary>
<div class="mado-ui-disclosure-content">
<p>No. The CLI copies source into the application.</p>
</div>
</details>
<details class="mado-ui-disclosure" name="project-faq">
<summary class="mado-ui-disclosure-summary">
Can the copied source be changed?
</summary>
<div class="mado-ui-disclosure-content">
<p>Yes. The application owns every installed file.</p>
</div>
</details>
</div>
</section>The browser closes another open details with the same name. Author no
more than one open disclosure in that group, but do not model it as a radio
group: the currently open disclosure can still close, leaving all of them
closed. The .mado-ui-disclosure-group wrapper supplies layout only; it does
not scope the native relationship. Treat name as page-wide, choose a
non-empty value unique to that logical group and never reuse it for unrelated
disclosures.
Exclusivity is opt-in because readers may need to compare two answers. Omit
name when keeping several sections open is useful. There is no disabled
summary contract in HTML or in this recipe; do not simulate one with
aria-disabled, event cancellation or CSS alone.
Browsers without support for named details groups keep every disclosure
independently usable; this recipe does not add a JavaScript exclusivity
polyfill.
The recipe intentionally adds no opening or closing animation. It installs as one CSS-only item, including both standalone and grouped presentation:
mado-ui add disclosurePublic properties:
| Purpose | Property |
|---|---|
| Surface | --mado-ui-disclosure-background, --mado-ui-disclosure-border, --mado-ui-disclosure-color, --mado-ui-disclosure-radius, --mado-ui-disclosure-shadow |
| Focus | --mado-ui-disclosure-focus |
| Summary | --mado-ui-disclosure-summary-background, --mado-ui-disclosure-summary-hover-background, --mado-ui-disclosure-summary-color, --mado-ui-disclosure-summary-font-weight, --mado-ui-disclosure-summary-min-block-size, --mado-ui-disclosure-summary-padding |
| Indicator | --mado-ui-disclosure-indicator-color, --mado-ui-disclosure-indicator-size |
| Content | --mado-ui-disclosure-content-gap, --mado-ui-disclosure-content-padding |
| Group | --mado-ui-disclosure-group-gap |
.mado-ui-popover styles an element using the browser's
popover attribute.
Use it for non-modal contextual content that may sit above a clipped panel
while the rest of the page remains available:
<button
id="resource-details-trigger"
class="mado-ui-button mado-ui-popover-invoker"
type="button"
popovertarget="resource-details"
>
Resource key /api/summary
</button>
<aside
id="resource-details"
class="mado-ui-popover"
popover="auto"
aria-labelledby="resource-details-title"
>
<header class="mado-ui-popover-header">
<h2
id="resource-details-title"
class="mado-ui-popover-title"
>
Resource cache details
</h2>
<p class="mado-ui-popover-description">
Current client cache contract.
</p>
</header>
<div class="mado-ui-popover-content">
<dl
class="mado-ui-description-list"
data-layout="stacked"
>
<div class="mado-ui-description-list-group">
<dt class="mado-ui-description-list-term">Endpoint</dt>
<dd class="mado-ui-description-list-value">
<code class="mado-ui-code">/api/summary</code>
</dd>
</div>
<div class="mado-ui-description-list-group">
<dt class="mado-ui-description-list-term">Fresh for</dt>
<dd class="mado-ui-description-list-value">20 seconds</dd>
</div>
</dl>
</div>
<footer class="mado-ui-popover-actions">
<button
class="mado-ui-button mado-ui-popover-dismiss"
type="button"
popovertarget="resource-details"
popovertargetaction="hide"
>
Close details
</button>
</footer>
</aside>Keep the popover immediately after its invoking button when practical. The
native popovertarget relationship provides declarative toggle behavior, an
implicit anchor and logical focus navigation even when top-layer rendering
places the surface elsewhere visually. Do not replace that relationship with
a click handler on a generic element or manually synchronize display,
hidden, open classes, aria-expanded, aria-hidden or aria-controls.
Always use type="button": a submit button with a form owner cannot be a
popover invoker.
The popover attribute adds behavior, not one universal semantic role. Choose
the element that matches the content: this supporting resource information is
an aside; navigation can use nav; a neutral grouping may use a named
section. Do not add role="dialog" or aria-modal="true" to an ordinary
popover. A menu, listbox and tooltip each require their own semantic and
keyboard contract and are separate registry items. Do not add
aria-haspopup to this generic surface.
popover="auto" is the normal choice for contextual content; write the value
explicitly so a typo cannot silently create manual behavior. It is
light-dismissed by an outside pointer action or Escape, and showing an
unrelated auto popover closes the existing one. The invoking button toggles it
when popovertargetaction is omitted. A native button with
popovertargetaction="hide" supplies an explicit close affordance without
application JavaScript.
An informational popover should not use autofocus: focus remains on its
invoker when it opens, and Tab reaches focusable content in the browser's
popover navigation order. The surface is non-modal, so focus is never trapped
and background content stays interactive. If a specialized picker or menu
needs initial focus, that item must define and test its own complete keyboard
model instead of changing this generic recipe.
popover="manual" does not light-dismiss, permits multiple independent open
surfaces and requires explicit show and hide controls or application calls.
Use it only for genuinely persistent application-owned UI with a visible
close action. Unlike auto, a manual popover does not record the previously
focused element for native focus restoration. If focus can enter it, the
application must focus the invoking control or the next logical target after
hiding it. This generic recipe defines no hover or focus trigger contract;
tooltip behavior belongs to the dedicated tooltip item.
Applications may observe beforetoggle and toggle, or call
showPopover(), hidePopover() and togglePopover() when product state
requires it. Those APIs remain application code. The CSS recipe never owns
visibility or cancellation. Opening can be vetoed through beforetoggle, but
closing an auto popover cannot be reliably vetoed; use manual or a modal
dialog when dismissal must be blocked. A programmatic open should pass its
invoker as showPopover({ source: trigger }) or
togglePopover({ source: trigger }); a bare call does not establish the
implicit anchor used by this recipe.
When
position-area
is supported, the recipe uses the invoker's implicit anchor and tries logical
block and inline flips to avoid viewport overflow. Override
--mado-ui-popover-position-area for a different preferred logical side. In
browsers without CSS Anchor Positioning, an open popover remains a centered
top-layer surface. The recipe also uses that centered placement below
30rem, where an anchor-side area could squeeze zoomed content too narrowly.
In a browser without the Popover API, the semantic content remains visible in
normal document flow. .mado-ui-popover-invoker and
.mado-ui-popover-dismiss are hidden there because their declarative actions
would be unavailable. A critical workflow should still have a real page or
in-flow native control as its baseline; this recipe does not ship a polyfill.
The surface stays within the viewport, scrolls tall content locally and wraps actions in source order. Its column flex layout lets short content keep its natural height while the content region can shrink and scroll below the configured maximum. This also avoids the still-open WebKit fixed grid intrinsic-sizing bug that can stretch a grid popover to the viewport or collapse its scrolling row after a mobile viewport relayout. The recipe adds no backdrop or entry/exit motion.
The example optionally composes button, code and description-list;
popover itself depends only on theme:
mado-ui add button code description-list popoverPublic properties:
| Purpose | Property |
|---|---|
| Surface | --mado-ui-popover-background, --mado-ui-popover-border, --mado-ui-popover-color, --mado-ui-popover-radius, --mado-ui-popover-shadow |
| Size | --mado-ui-popover-inline-size, --mado-ui-popover-max-block-size, --mado-ui-popover-padding |
| Layout | --mado-ui-popover-gap, --mado-ui-popover-header-gap, --mado-ui-popover-content-gap |
| Description | --mado-ui-popover-description-color |
| Actions | --mado-ui-popover-actions-gap, --mado-ui-popover-actions-justify |
| Placement | --mado-ui-popover-offset, --mado-ui-popover-position-area |
madoUiTooltip() connects one enabled native action to one short, plain
description. A tooltip supplements a trigger that is already understandable;
it must never be the only place that exposes an action's name, an error,
required instructions or other essential information.
Inside a light-DOM page() or layout view, create one controller during setup
and bind both refs in the same template:
import { html } from "@madojs/mado";
import { madoUiTooltip } from "./shared/ui/mado-ui-tooltip.js";
const copyHelp = madoUiTooltip();
return html`
<button
class="mado-ui-button mado-ui-tooltip-trigger"
type="button"
interestfor="copy-command-help"
aria-describedby="keyboard-shortcut copy-command-help"
ref=${copyHelp.trigger}
>
Copy install command
</button>
<span id="keyboard-shortcut">
Keyboard shortcut: C.
</span>
<span
id="copy-command-help"
class="mado-ui-tooltip"
role="tooltip"
popover="hint"
ref=${copyHelp.tooltip}
>
Copies the command without leaving the current page.
</span>
`;The authored
interestfor
relationship and popover="hint" delegate visibility, implicit anchoring and
dismissal to native Interest Invokers in supporting browsers. The helper
validates the complete authored contract but does not install duplicate event
listeners when that native relationship is active.
Interest Invokers are still a progressive platform proposal rather than a
cross-browser baseline; non-supporting browsers ignore interestfor, and the
helper below remains the required behavior fallback.
Where the Popover API and CSS Anchor Positioning are available but Interest
Invokers are not, the helper progressively installs matching pointer, focus
and Escape behavior. It calls showPopover({ source: trigger }), preserving
the implicit anchor instead of calculating coordinates in JavaScript. In a
browser that cannot render an anchored popover, the visual hint stays absent;
the authored aria-describedby relationship remains the semantic baseline.
Do not add a manually positioned, always-visible or click-toggled fallback.
The trigger must be either an enabled native <button> or an <a href>.
Write type="button" unless a button intentionally submits a form, and do
not use a disabled button as a tooltip trigger. The trigger requires:
.mado-ui-tooltip-trigger;interestforexactly matching the tooltip's unique, non-emptyid;aria-describedbycontaining that same ID;- one
reffrom the controller'striggerbinding.
aria-describedby is a space-separated token list, so an existing
description may remain alongside the tooltip ID as in the example. The helper
does not rewrite or discard those authored relationships. Do not add a
competing title attribute, aria-expanded, aria-haspopup or
aria-controls.
The separate tooltip surface requires:
- the matching unique
id; .mado-ui-tooltip;role="tooltip"and the exactpopover="hint"value;- one
reffrom the same controller'stooltipbinding; - concise descriptive text.
Do not put the tooltip inside its trigger, give it tabindex or manually add
hidden. A tooltip may contain static text markup, but it must not contain a
link, button, form control, summary, editable region, media controls or any
other focusable content. Use a generic popover or dialog when the surface
needs interaction.
The native and progressively enhanced paths share one interaction contract:
- focus or a non-touch pointer entering the trigger schedules the tooltip
after
500ms; - moving the pointer between the trigger and its tooltip keeps it visible;
- leaving both surfaces after focus has left schedules dismissal after
300ms; Escapedismisses an open or pending tooltip immediately without moving focus;- the tooltip never receives focus, traps
Tab, intercepts the trigger's activation or reports expanded state; - after Escape, the same continuous hover or focus does not reopen it; the user must leave and express interest again.
Only one progressively enhanced tooltip is visible at a time. The paired ref disposers cancel pending timers and remove helper-owned listeners when either side leaves the template.
The JavaScript fallback ignores touch pointers. It does not invent a long-press gesture or turn the trigger's click into a tooltip toggle, because either behavior would compete with native activation and platform gestures. Keep essential help visible near the control and treat the tooltip as supplementary.
The delays are public properties on .mado-ui-tooltip-trigger. Values must
include ms or s so both the native and JavaScript paths interpret them:
.destructive-action {
--mado-ui-tooltip-show-delay: 750ms;
--mado-ui-tooltip-hide-delay: 300ms;
}The item depends only on theme; the button recipe in the example is
optional:
mado-ui add button tooltipPublic properties:
| Purpose | Property |
|---|---|
| Timing | --mado-ui-tooltip-show-delay, --mado-ui-tooltip-hide-delay |
| Surface | --mado-ui-tooltip-background, --mado-ui-tooltip-border, --mado-ui-tooltip-color, --mado-ui-tooltip-radius, --mado-ui-tooltip-shadow |
| Size | --mado-ui-tooltip-max-inline-size, --mado-ui-tooltip-max-block-size, --mado-ui-tooltip-padding, --mado-ui-tooltip-font-size |
| Placement | --mado-ui-tooltip-offset, --mado-ui-tooltip-position-area |
madoUiMenu() progressively enhances a declarative popover containing native
links and buttons into a real
menu button
composite. Use it for a compact set of secondary commands associated with one
object, such as actions for a table row. Keep the primary action visible
outside the menu.
Inside a light-DOM page() or layout view, create one controller during setup
and bind both of its refs in the same template:
import { html, signal } from "@madojs/mado";
import { madoUiMenu } from "./shared/ui/mado-ui-menu.js";
const invoiceActions = madoUiMenu();
const result = signal("No invoice action has run.");
return html`
<a href="/invoices/inv-1002">INV-1002</a>
<button
id="invoice-actions-trigger"
class="mado-ui-button mado-ui-popover-invoker"
type="button"
popovertarget="invoice-actions"
aria-label="Actions for INV-1002"
ref=${invoiceActions.trigger}
>
Actions
</button>
<div
id="invoice-actions"
class="mado-ui-popover mado-ui-menu"
popover="auto"
aria-labelledby="invoice-actions-trigger"
ref=${invoiceActions.menu}
>
<a
class="mado-ui-menu-item"
href="/invoices/inv-1002/edit"
>
Edit invoice
</a>
<button
class="mado-ui-menu-item"
type="button"
@click=${() => result.set("Copied INV-1002.")}
>
Copy invoice number
</button>
<hr class="mado-ui-menu-separator">
<button
class="mado-ui-menu-item"
type="button"
@click=${() => result.set("INV-1002 marked as paid.")}
>
Mark as paid
</button>
</div>
<p role="status">${() => result()}</p>
`;The authored elements deliberately have no role="menu",
role="menuitem", tabindex or manually synchronized aria-expanded.
When the Popover API and the helper are both active, the helper adds the
composite roles, roving tabindex and aria-haspopup="menu". The native
popovertarget relationship exposes expanded state to the accessibility tree,
owns light dismissal and supplies the implicit anchor. The application keeps
ownership of links, commands, routing, mutations and result announcements.
Without the Popover API, the trigger is hidden by
.mado-ui-popover-invoker and the links and buttons remain visible as ordinary
native controls in document order. Without the helper, the popover remains a
generic non-modal surface with normal Tab navigation; it must not be authored
with menu roles because the required keyboard model would be absent.
The trigger must be a native <button type="button"> whose
popovertarget matches the menu's non-empty id. The menu must use
popover="auto", .mado-ui-popover, .mado-ui-menu and
aria-labelledby pointing to that trigger. Use one controller instance for
exactly one trigger/menu pair.
Menu items are direct children:
- navigation is an
<a href>with.mado-ui-menu-item; - a command is a
<button type="button">with.mado-ui-menu-item; - a separator is an
<hr class="mado-ui-menu-separator">.
Keep each item to one concise accessible label. Decorative icons, metadata or
shortcut hints inside a menu item are presentational because descendants of a
menuitem are not represented as independent semantics.
Use aria-disabled="true" when an unavailable item must remain discoverable
through arrow navigation. The helper blocks pointer and keyboard activation.
A disabled link must also omit href; aria-disabled alone never removes
native link navigation. Do not use the native disabled attribute for an
enhanced menu item because it prevents the focus required by the composite
pattern. If unavailable actions do not need to be discoverable, omit them
from the template instead.
The first contract intentionally supports only plain menuitem links and
commands. It rejects nested menus, items with aria-haspopup,
menuitemcheckbox, menuitemradio and arbitrary clickable elements. Those
patterns need additional state and keyboard behavior and will be separate
extensions only when a real product scenario requires them.
The helper implements the complete vertical menu contract:
- pointer,
EnterorSpaceon the trigger opens the menu and focuses its first item; ArrowDownon the trigger opens at the first item andArrowUpopens at the last item;ArrowDownandArrowUpmove cyclically between all items, including discoverable disabled items;HomeandEndmove to the first and last item;- printable characters perform buffered, wrapping prefix search across item labels, including localized text;
EnterandSpaceactivate the focused native link or button exactly once;Escapecloses the menu and returns focus to its trigger;TabandShift+Tabare never trapped or cancelled: the browser moves to the next logical control and the menu closes after focus leaves it.
Outside pointer dismissal stays browser-owned. Activating an item normally closes the menu and returns focus to its trigger. If the application action has deliberately moved focus elsewhere, for example into a newly opened dialog or to the start of a new view, the helper preserves that destination. An empty menu does not open; hide or disable its trigger instead of presenting an empty composite.
Items are rediscovered when the menu opens or receives interaction, so normal
Mado conditional templates can add or remove actions. Remove the whole
trigger/menu pair together. The two ref() disposers remove listeners and
restore helper-owned roles and tabindex values during template cleanup.
For programmatic opening, preserve the native invoker relationship:
menu.showPopover({ source: trigger });A bare showPopover() call has no source for focus restoration or implicit
anchor positioning. The application should not call focus() after opening;
the helper owns entry focus.
Do not use this component for a site navigation list, select replacement, tooltip, generic form controls inside a popover or a row of simultaneously visible actions. Those retain their native link, select, popover or button contracts. Install the helper, its styles and the optional button recipe:
mado-ui add button menumenu installs popover and theme transitively. Import the copied helper
where its controller is created, and import all installed CSS from the
application entrypoint.
Public properties:
| Purpose | Property |
|---|---|
| Surface | --mado-ui-menu-inline-size, --mado-ui-menu-max-block-size, --mado-ui-menu-padding, --mado-ui-menu-gap |
| Item | --mado-ui-menu-item-background, --mado-ui-menu-item-color, --mado-ui-menu-item-padding, --mado-ui-menu-item-gap, --mado-ui-menu-item-min-block-size, --mado-ui-menu-item-radius |
| Item states | --mado-ui-menu-item-focus-background, --mado-ui-menu-item-disabled-opacity |
| Separator | --mado-ui-menu-separator-color, --mado-ui-menu-separator-margin |
| Trigger target | --mado-ui-menu-trigger-size |
madoUiTabs() progressively enhances useful same-page links and visible
sections into one
tablist with automatic
activation. The application keeps its content in light DOM; the helper owns
only the composite semantics, selected state, panel visibility and keyboard
behavior.
Create one controller during page or layout setup and bind its root ref to the tab set:
import { html } from "@madojs/mado";
import { madoUiTabs } from "./shared/ui/mado-ui-tabs.js";
const settingsTabs = madoUiTabs();
return html`
<div class="mado-ui-tabs" ref=${settingsTabs.root}>
<div class="mado-ui-tabs-list" aria-label="Product settings">
<a
id="account-tab"
class="mado-ui-tabs-tab"
href="#account-panel"
>
Account
</a>
<a
id="notifications-tab"
class="mado-ui-tabs-tab"
href="#notifications-panel"
>
Notifications
</a>
<a
id="billing-tab"
class="mado-ui-tabs-tab"
href="#billing-panel"
>
Billing
</a>
</div>
<div class="mado-ui-tabs-panels">
<section id="account-panel" class="mado-ui-tabs-panel">
<h2>Account profile</h2>
<p>Update the public name and contact address.</p>
</section>
<section id="notifications-panel" class="mado-ui-tabs-panel">
<h2>Notification delivery</h2>
<p>Choose which product activity is delivered by email.</p>
</section>
<section id="billing-panel" class="mado-ui-tabs-panel">
<h2>Billing details</h2>
<p>Review the current plan and next renewal date.</p>
</section>
</div>
</div>
`;Import mado-ui-tabs.css once from the application entrypoint. The item
depends only on theme:
mado-ui add tabsThe authored markup is intentionally useful before JavaScript runs. Every tab
is a native <a> with a local fragment href, and every panel is a visible
native <section> with its own heading. A link therefore moves readers to its
section in the static document. Do not author role, tabindex,
aria-selected, aria-controls, aria-labelledby on panels, or hidden;
those values would claim tab behavior before it exists.
The root must be one .mado-ui-tabs div. It has exactly two direct children:
- one
.mado-ui-tabs-listdivwith either a non-emptyaria-labeloraria-labelledby; - one
.mado-ui-tabs-panelsdiv.
The list contains only direct .mado-ui-tabs-tab anchors. Every anchor needs a
non-empty id that is unique across the document and an href containing only
# plus the document-unique ID of one panel in the same root. The panels
wrapper contains the matching direct .mado-ui-tabs-panel sections. Each panel
belongs to exactly one tab. Nested or sibling tab sets may appear on the same
page, but they require their own controller and must not reuse tab or panel IDs
from any other set.
On connection the helper validates the complete structure before changing it,
then adds the tablist, tab and tabpanel roles and their ID relationships. The
panel referenced by the page's initial fragment becomes selected when it
belongs to this set; otherwise the first tab is selected. All other tabs leave
the sequential Tab order and all inactive panels receive the helper-owned
data-mado-ui-tabs-state="inactive" state. The stylesheet removes those
panels from layout and the accessibility tree with display: none; the
helper deliberately does not add
hidden,
because the authored fragment links remain present. The active panel receives
tabindex="0" so keyboard users can enter its content even when it contains
no focusable controls.
When the root leaves its Mado template, the ref disposer removes listeners and restores every helper-owned attribute. The original fragment links and visible sections become the complete fallback again.
Tab and panel pairs may be inserted, removed or reordered together by one Mado
update. The helper keeps the selected panel by ID when it still exists and
decorates the final direct children after the DOM commit. An incomplete update
fails open instead of leaving false ARIA semantics or hidden content: every
section becomes visible and the fragment links work normally. The same
controller automatically enhances the root again after its one-to-one
structure is repaired. Replace a pair when changing its IDs or href; do not
mutate helper-owned ARIA or state attributes.
The initial horizontal set has one selected tab and one tab stop:
- clicking a tab selects it and reveals its panel without changing the URL fragment;
ArrowRightandArrowLeftmove focus through tabs in DOM order and select the focused tab immediately;HomeandEndfocus and select the first and last tab;Tabis never trapped or cancelled and moves from the selected tab into the active panel, whosetabindex="0"supplies a predictable entry point;- unrelated keys and modified keyboard shortcuts remain browser-owned.
Automatic activation is deliberate because every panel is already present in the local document. Do not start network work from tab focus. Load remote data before activation or use an ordinary navigation list when switching views can be slow.
For a vertical set, put the presentation attribute on the root:
<div
class="mado-ui-tabs"
data-orientation="vertical"
>
...
</div>The helper then exposes aria-orientation="vertical" on the tablist and uses
ArrowDown and ArrowUp; Left and Right remain browser-owned. The first
contract deliberately has no manual-activation mode, disabled tabs, closeable
tabs or ongoing URL/hash synchronization. It reads a matching initial fragment
for progressive entry, but activation neither writes history nor listens for
later hash changes. Omit an unavailable destination
instead of inventing a disabled anchor. Use application routing rather than
Tabs when a selection must be addressable, reloadable or represented in
browser history.
Public properties:
| Purpose | Property |
|---|---|
| Root | --mado-ui-tabs-color, --mado-ui-tabs-gap, --mado-ui-tabs-focus |
| List | --mado-ui-tabs-list-background, --mado-ui-tabs-list-border, --mado-ui-tabs-list-gap, --mado-ui-tabs-list-padding, --mado-ui-tabs-list-radius |
| Tab | --mado-ui-tabs-tab-background, --mado-ui-tabs-tab-color, --mado-ui-tabs-tab-min-block-size, --mado-ui-tabs-tab-padding, --mado-ui-tabs-tab-radius |
| Tab states | --mado-ui-tabs-tab-hover-background, --mado-ui-tabs-tab-hover-color, --mado-ui-tabs-tab-selected-background, --mado-ui-tabs-tab-selected-border, --mado-ui-tabs-tab-selected-color, --mado-ui-tabs-tab-selected-shadow |
| Panels | --mado-ui-tabs-panels-gap, --mado-ui-tabs-panel-background, --mado-ui-tabs-panel-border, --mado-ui-tabs-panel-color, --mado-ui-tabs-panel-padding, --mado-ui-tabs-panel-radius, --mado-ui-tabs-panel-shadow |
Layout recipes change visual flow only. They never add roles, reorder the DOM or hide overflow.
<div class="mado-ui-stack">...</div>
<div class="mado-ui-cluster">...</div>
<div
class="mado-ui-grid"
style="--mado-ui-grid-min: 14rem"
>
...
</div>Public properties:
| Recipe | Properties |
|---|---|
| Stack | --mado-ui-stack-gap, --mado-ui-stack-align, --mado-ui-stack-justify |
| Cluster | --mado-ui-cluster-gap, --mado-ui-cluster-align, --mado-ui-cluster-justify, --mado-ui-cluster-wrap |
| Grid | --mado-ui-grid-gap, --mado-ui-grid-min, --mado-ui-grid-align |
The grid uses container-driven auto-fit and safely collapses below its
preferred minimum without application breakpoints.
mado-ui-avatar presents a square image or a named fallback in one stable
surface. Its public attributes are exactly src and alt; fallback content
uses the named fallback slot:
<mado-ui-avatar src="/people/ada.webp" alt="Ada Lovelace">
<span slot="fallback">AL</span>
</mado-ui-avatar>
<span class="mado-ui-cluster">
<mado-ui-avatar src="/people/grace.webp" alt="">
<span slot="fallback">GH</span>
</mado-ui-avatar>
<span>Grace Hopper</span>
</span>Every instance must make an explicit alternative-text choice. A non-empty
alt makes the stable avatar surface a single role="img" with that value
as its accessible name. Use replacement text such as the person's name, not
"Avatar of Ada Lovelace". Use the exact empty value alt="" when the
identity is already written next to the avatar or the graphic is otherwise
decorative. Missing alt is safely hidden by the element, but it is not an
authoring substitute for that explicit choice. A whitespace-only value is
also hidden defensively; authors must still use the exact alt="" contract
for decorative content. Surrounding whitespace on an informative value is
trimmed before it becomes the accessible name.
The internal native image always has alt="" and is hidden from assistive
technology. The fallback wrapper is hidden from assistive technology as well,
so initials never become a second, abbreviated accessible name. This leaves
one stable image name for loading, success and error instead of exchanging
accessible nodes when a request fails. It follows the
HTML alternative-text guidance,
the HTML image accessibility mapping
and the requirement that an ARIA
img role has an accessible name.
For a decorative native image, an empty alt is distinct from omitting the
attribute, as described by
WCAG technique H67.
Fallback content is static visual content only. Do not put a link, button,
form control, editable content or anything with tabindex in the hidden
fallback slot. The avatar itself is not an action. If it is placed inside a
profile link, account button or other control, set alt="" on the avatar and
give the native control an application-owned label that describes the action:
<a href="/people/ada" aria-label="Open Ada Lovelace's profile">
<mado-ui-avatar src="/people/ada.webp" alt="">
<span slot="fallback">AL</span>
</mado-ui-avatar>
</a>The fallback is visible initially and while src is loading. A missing,
empty or whitespace-only src does not create an internal image. A successful
load reveals the image; an error leaves the fallback visible without exposing
a broken-image icon or changing accessible semantics. Changing src
immediately returns the surface to fallback while the new request is pending.
Cached completion is handled after rendering, and completion from an obsolete
source must not overwrite the current source state. An empty fallback slot
intentionally produces a blank themed surface; the element does not generate
initials, an icon or alternative text.
Changing alt updates only the stable surface's accessible name or decorative
state. Loading errors do not announce themselves or move focus. Retries,
announcements and application data remain outside the public API: there are no
public state attributes or custom lifecycle events.
Shadow parts:
| Part | Purpose |
|---|---|
avatar |
Stable clipped surface and accessible image container |
image |
Native image shown only after the current source loads |
fallback |
Wrapper for the named fallback slot |
Public properties are --mado-ui-avatar-size,
--mado-ui-avatar-radius, --mado-ui-avatar-background,
--mado-ui-avatar-color, --mado-ui-avatar-border,
--mado-ui-avatar-font-size and --mado-ui-avatar-font-weight.
Use the recipe only on a native horizontal rule that represents a thematic change within content:
<p>Installation configures the local registry.</p>
<hr class="mado-ui-separator">
<p>Adding an item copies its source into the application.</p>The HTML
hr element
already maps to
separator. Do not add a
redundant role="separator", aria-orientation, accessible name or
tabindex. The element is a static document structure, not a control.
A line used only for visual decoration is not a thematic break. Draw it with
an existing layout border or pseudo-element instead of adding an hr.
Likewise, a static vertical
role="separator" with
aria-orientation="vertical" belongs to application layout because its
visual and accessible orientations must stay synchronized. A focusable,
movable separator is an interactive splitter with required range semantics
and keyboard behavior; it is a separate component and explicitly outside this
item.
Customize the horizontal rule with --mado-ui-separator-inline-size,
--mado-ui-separator-margin-block, --mado-ui-separator-thickness and
--mado-ui-separator-color. It uses a logical block-start border and remains
bounded by its container. Forced-colors mode replaces the authored border
color with the browser's CanvasText system color.
Use a native progress element. Include value for determinate progress and
omit it completely for indeterminate progress:
<label for="build-progress">Build progress</label>
<progress
id="build-progress"
class="mado-ui-progress"
value="68"
max="100"
>
68%
</progress>
<progress
class="mado-ui-progress"
aria-label="Loading preview"
></progress>Do not duplicate its implicit progressbar role or native value ARIA. Put
aria-busy on the region being updated, not on the progress element.
Indeterminate movement is disabled when reduced motion is requested.
Customize it with --mado-ui-progress-height,
--mado-ui-progress-track, --mado-ui-progress-value,
--mado-ui-progress-radius and --mado-ui-progress-duration.
meter represents a scalar value within a known range, not task progress:
<label for="storage-meter">Storage health</label>
<meter
id="storage-meter"
class="mado-ui-meter"
min="0"
max="100"
low="35"
high="80"
optimum="90"
value="72"
>
72%
</meter>Native low, high and optimum decide whether the value is optimal,
suboptimal or critical. Keep a text value or state next to the meter whenever
the distinction has product meaning; color must not be the only signal.
Its range colors use --mado-ui-meter-optimum,
--mado-ui-meter-suboptimal and --mado-ui-meter-critical.
The skeleton is decorative geometry for a content region that is still loading. It does not expose progress, status or image semantics, and it never owns the request lifecycle:
<section
id="projects"
aria-labelledby="projects-title"
aria-busy="true"
>
<h2 id="projects-title">Projects</h2>
<p>Loading projects…</p>
<div aria-hidden="true">
<span
class="mado-ui-skeleton"
style="
--mado-ui-skeleton-inline-size: 3rem;
--mado-ui-skeleton-block-size: 3rem;
--mado-ui-skeleton-radius: var(--mado-ui-radius-full);
"
></span>
<span class="mado-ui-skeleton"></span>
<span
class="mado-ui-skeleton"
style="--mado-ui-skeleton-inline-size: 72%"
></span>
</div>
</section>
<!-- Present before a client-side request and outside the busy region. -->
<p id="projects-status" role="status" aria-atomic="true"></p>Place aria-hidden="true" on each placeholder or on their nearest common
wrapper. That hidden subtree must stay empty of links, buttons, form controls
and anything with tabindex:
aria-hidden hides every
descendant from assistive technology. Do not assign status, progressbar,
img, an accessible label or placeholder characters to a skeleton. Provide
real visible loading copy, and put
aria-busy on the stable
region whose content is changing. aria-busy reports an update; it does not
disable pointer or keyboard interaction.
Initial loading markup delivered with the page is not a live change and does
not need a live-region role. For a request that begins after the page becomes
interactive, keep a role="status"
node with aria-atomic="true" in the DOM before the request and outside the
busy region. Update it with concise loading and completion text without moving
focus. Insert the coherent real content, remove the decorative placeholders,
then set aria-busy="false" after the last related mutation. Use the native
hidden
attribute when application content must not yet be rendered; do not merely
make real focusable content transparent or place it under aria-hidden.
The default shimmer makes three passes of 1.6s and then leaves a static
placeholder. It exists only under
prefers-reduced-motion: no-preference;
reduced-motion and forced-colors users receive a static, visible shape. Do not
turn it into an endless decorative animation. If
--mado-ui-skeleton-duration is increased, keep the three-pass total at or
below five seconds unless the application supplies the pause behavior required
by WCAG Pause, Stop, Hide.
Use native progress when completion is measurable, and keep stale usable
content visible during a background refresh instead of replacing it with a
skeleton.
Shape remains open to the application rather than becoming a fixed
data-shape API. Set equal logical dimensions and a full radius for a circle;
set a taller block size for media; repeat normal line placeholders for text.
Public properties are --mado-ui-skeleton-inline-size,
--mado-ui-skeleton-block-size, --mado-ui-skeleton-radius,
--mado-ui-skeleton-background, --mado-ui-skeleton-highlight and
--mado-ui-skeleton-duration.
The spinner is decorative and intentionally has no role of its own:
<div role="status" class="mado-ui-cluster">
<span class="mado-ui-spinner" aria-hidden="true"></span>
<span>Loading results…</span>
</div>Always pair it with visible or otherwise accessible status text. Inside a
pending button, keep the button text and set aria-busy="true" on the button.
The ring remains recognizable but stops rotating under reduced motion.
Public properties are --mado-ui-spinner-size,
--mado-ui-spinner-stroke, --mado-ui-spinner-color,
--mado-ui-spinner-track and --mado-ui-spinner-duration.
All recipe rules use :where() inside @layer mado-ui. Application selectors
outside a layer override them without specificity escalation. Public custom
properties start with --mado-ui-; edit the copied source when a product needs
a different contract. Theme colors are authored in OKLCH so neutral ramps and
light/dark adjustments remain perceptually predictable. Forced-colors mode
intentionally replaces them with browser system colors.