Skip to content

Latest commit

 

History

History
1569 lines (1313 loc) · 63 KB

primitives.md

File metadata and controls

1569 lines (1313 loc) · 63 KB

Primitive contracts

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.

Button and link

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.

Form controls

.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.

Editable combobox

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.

Native fallback and enhancement

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-controls and aria-expanded;
  • gives the generated popup and suggestions listbox and option semantics.

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.

Focus, keyboard and pointer model

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.

Value, events and forms

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 combobox

Code

Use 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.

Native dialog

.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.

Focus model

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 dialog

Public 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

Native disclosure

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.

Optional exclusive group

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 disclosure

Public 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

Native popover

.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.

Dismissal and focus

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.

Positioning and fallback

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 popover

Public 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

Tooltip

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.

Markup contract

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;
  • interestfor exactly matching the tooltip's unique, non-empty id;
  • aria-describedby containing that same ID;
  • one ref from the controller's trigger binding.

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 exact popover="hint" value;
  • one ref from the same controller's tooltip binding;
  • 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.

Focus and interest model

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;
  • Escape dismisses 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 tooltip

Public 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

Dropdown menu

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.

Markup contract

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.

Focus and keyboard model

The helper implements the complete vertical menu contract:

  • pointer, Enter or Space on the trigger opens the menu and focuses its first item;
  • ArrowDown on the trigger opens at the first item and ArrowUp opens at the last item;
  • ArrowDown and ArrowUp move cyclically between all items, including discoverable disabled items;
  • Home and End move to the first and last item;
  • printable characters perform buffered, wrapping prefix search across item labels, including localized text;
  • Enter and Space activate the focused native link or button exactly once;
  • Escape closes the menu and returns focus to its trigger;
  • Tab and Shift+Tab are 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 menu

menu 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

Tabs

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 tabs

Static and enhanced contracts

The 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-list div with either a non-empty aria-label or aria-labelledby;
  • one .mado-ui-tabs-panels div.

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.

Focus and keyboard model

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;
  • ArrowRight and ArrowLeft move focus through tabs in DOM order and select the focused tab immediately;
  • Home and End focus and select the first and last tab;
  • Tab is never trapped or cancelled and moves from the selected tab into the active panel, whose tabindex="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

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.

Avatar

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.

Separator

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.

Progress

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

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.

Skeleton

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.

Spinner

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.

Customization

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.