Give readers familiar controls with clear labels, keyboard focus, and predictable opening and closing behavior.
Native HTML controls use Core's shared styling;
React interfaces can use @pathmx/ui for the same appearance and interaction rules.
This page covers working controls and their styling: button variants and tokens, form fields, touch behavior, dialogs, floating islands, and progress.
Core's Theme Plugin styles opted-in buttons and dialog surfaces. Native HTML and
@pathmx/ui React components use the same tokens.
<button class="pmx-button" data-variant="outline" type="button">Continue</button>
<button class="pmx-button" data-variant="ghost" data-shape="icon"
type="button" aria-label="Zoom in">+</button>
<dialog class="pmx-dialog" aria-label="Connections">…</dialog>
Buttons support data-variant="primary|outline|ghost|danger|floating" (default:
primary), data-shape="icon", and compact data-size="sm". Icon actions need
an accessible name. These classes supply appearance; the owning component supplies
behavior. Use Core's icon catalog for production icon geometry.
| Token | Default | Controls |
|---|---|---|
--pmx-control-size | 2.5rem | Regular height and icon-action width. |
--pmx-control-size-sm | 2.25rem | Compact desktop size; touch targets remain at least 44px. |
--pmx-control-radius | var(--pmx-radius-full) | Button corners. |
--pmx-control-icon-size | 1.2rem | Button icon dimensions. |
--pmx-control-icon-stroke | 1.75 | Button icon stroke weight. |
--pmx-motion-duration | 160ms | Shared state-transition duration. |
--pmx-motion-ease | ease-out | Shared transition easing. |
Dialog surfaces share the floating island treatment described below. Focus uses --pmx-color-focus; filled actions use
--pmx-color-on-accent or --pmx-color-on-danger for their text. Reduced-motion
preferences disable control transitions. Set these CSS properties in your theme
stylesheet; no additional frontmatter settings are required.
Native text inputs, selects, and textareas receive themed surfaces, borders,
typography, and focus rings without a component wrapper. Use .pmx-field to
arrange a label, control, and supporting text together:
<div class="pmx-field">
<label for="email">Email</label>
<input id="email" type="email" name="email" autocomplete="email"
aria-describedby="email-hint" />
<small id="email-hint">Where should we send your reply?</small>
</div>
A separate <label for="email"> and <input id="email"> works too. Placeholders
supplement a label; they do not replace it. Inputs keep their native behavior,
including select menus, date pickers, autofill, and textarea resizing.
Use disabled for unavailable fields and readonly for values that can be
selected and copied but not edited. Mark a known error with aria-invalid="true"
and connect its explanation with aria-describedby. Put the explanation in
.pmx-field-error. Native validation failures use the same border treatment
after user interaction; required fields are not marked invalid on initial load.
Styles do not generate error messages or perform validation.
Checkboxes and radio buttons retain native indicators using the accent color.
Wrap an option and its text in .input-option for a full clickable row. Group
related options in <fieldset class="pmx-fieldset"> with a <legend>. Range,
file, color, hidden, and button inputs keep their specialized layout rather
than receiving text-field styling.
The defaults use existing color, font, and radius tokens, work in light and dark schemes, and remain easy to override with ordinary class selectors. See the Form controls example to try the field types and states.
The default Theme Plugin supplies touch behavior for native buttons, links,
inputs, selects, and disclosure summaries, whether or not they use
.pmx-button. Custom elements with interactive ARIA roles receive the same
touch-action: manipulation default. Repeated taps activate controls without
requesting double-tap zoom; scrolling and pinch-to-zoom remain available.
Ordinary prose keeps the browser's normal gestures.
Prefer native elements for their built-in keyboard and accessibility behavior:
<label class="pmx-field">
Your name
<input name="name" autocomplete="name" />
</label>
<details>
<summary>More options</summary>
<label class="pmx-field">
Playback speed
<select name="speed">
<option value="1">Normal</option>
<option value="1.5">1.5×</option>
</select>
</label>
</details>
Editable native fields default to at least 16px text on devices with a coarse pointer, avoiding Safari's input-focus zoom. Shared button styles provide 44px minimum touch targets. Custom styles can override these defaults; keep editable text at least 16px and preserve a usable focus indicator and tap area.
An ARIA role describes a custom control; it does not add keyboard interaction
or state management. Supply those behaviors when native HTML cannot express
the control. Shadow DOM controls should apply touch-action: manipulation to
their host or interaction surface, since document selectors do not cross the
shadow boundary. A slider or canvas that owns a drag gesture can override
touch-action on that surface. Limit the override to the gesture's target;
avoid disabling zoom across the page or enclosing reading content.
Follow Add a browser module
for the complete repository entrypoint. Replace its plugins/counter/client.ts
with the code below and open /counter.page. The page receives an Open help
button; click it, dismiss with Escape, and confirm focus returns to that button.
Browser clients that render ordinary HTML can use nativeModal from
@pathmx/core/controls/browser. Initialize it once for each mounted dialog:
import { mountPathMXBehavior, nativeModal } from "@pathmx/core/controls/browser"
mountPathMXBehavior("[data-counter]", (root, { signal }) => {
const trigger = document.createElement("button")
trigger.textContent = "Open help"
const dialog = document.createElement("dialog")
dialog.className = "pmx-dialog"
dialog.setAttribute("aria-label", "Help")
const close = document.createElement("button")
close.textContent = "Close"
dialog.append("Choose a lesson to begin. ", close)
root.append(trigger, dialog)
const modal = nativeModal(dialog)
trigger.addEventListener("click", () => modal.open(trigger), { signal })
close.addEventListener("click", () => modal.close(), { signal })
return { dispose() { modal.dispose(); trigger.remove() } }
})
Opening uses the native modal focus behavior and locks background scrolling.
Escape, a pointer press and release outside the dialog, or close() dismisses
it and restores scroll and focus. Opening another managed native dialog closes
the previous one. Dialog key events do not trigger document-level shortcuts.
Optional onClose and returnFocus callbacks let the owner react to dismissal
and select a replacement focus target when its trigger changes. dispose()
closes and removes the dialog; create a new lifecycle for a new element.
For React-owned interfaces, use the Dialog primitives in the next example.
Use an initialized Quickstart project. Install:
bun add @pathmx/react@beta @pathmx/ui@beta react@^19 react-dom@^19
bun add --dev @types/react @types/react-dom
@pathmx/ui is a library, so it needs no pathmx.plugins activation. Create
plugins/help.plugin.ts:
import { defineComponent, moduleRef } from "@pathmx/core"
export default defineComponent({
tag: "x-help-dialog",
render: () => ({
html: '<div><details><summary>Help</summary><p>Choose a lesson to begin.</p></details></div>',
clientData: {},
}),
client: moduleRef(() => import("./help.client.tsx")),
})
The native disclosure is useful before JavaScript loads or when it is unavailable. The browser app replaces the region with a dialog trigger.
Create plugins/help.client.tsx:
import { reactClient } from "@pathmx/react"
import {
Dialog, DialogTrigger, DialogContent,
DialogTitle, DialogDescription, DialogClose,
} from "@pathmx/ui"
export default reactClient(() => (
<Dialog>
<DialogTrigger className="pmx-button">Open help</DialogTrigger>
<DialogContent>
<DialogTitle>Help</DialogTitle>
<DialogDescription>Choose a lesson to begin.</DialogDescription>
<DialogClose className="pmx-button">Close</DialogClose>
</DialogContent>
</Dialog>
))
Create paths/help.page.md:
# Workshop help
<x-help-dialog />
Run bun run dev and open /help.page. Open help, move through its controls with
Tab, then close with Escape or Close. Focus returns to Open help. Try the same
interaction on a phone and in Player. mountPathMXApps owns mounting and cleanup
when that host leaves the page; see the React lifecycle.
Core's Theme Plugin supplies .pmx-button and .pmx-dialog styling. The UI package
loads React-specific styles; an explicit CSS entrypoint is available as
@pathmx/ui/styles.css.
Wrap a primary action and adjacent chooser button in .pmx-split-control.
Use .pmx-button on both direct children. They share outer corners and a divider
while retaining separate keyboard focus targets. Use an anchor for navigation;
never nest the chooser button inside that anchor. The owning plugin supplies
chooser behavior and hides its trigger until it is ready.
Player provides a complete split action:
[@link.play Play this guide](../learning/player.reference.md)
The primary link starts Focus. The chevron offers Focus or Slides and starts the
chosen mode immediately. .play.slides makes Slides the default. See the
Player reference.
Use .pmx-island for a floating card that augments the surrounding content:
<aside class="pmx-island source-details" aria-label="Selected Source">
<a href="/lessons/color.lesson">Color relationships</a>
<p>Lesson</p>
</aside>
The class supplies the surface and padding; your CSS owns placement and content
layout. Floating buttons (data-variant="floating") and native/React
.pmx-dialog surfaces share its translucent background, border, blur, and
elevation. Buttons retain their circular or pill shape. Dialog behavior remains
with native HTML or the React adapter.
| Variable | Default |
|---|---|
--pmx-island-background | Theme surface at 84% opacity |
--pmx-island-border | var(--pmx-color-border) |
--pmx-island-radius | 1.75rem |
--pmx-island-shadow | var(--pmx-shadow-lg) |
--pmx-island-blur | 20px |
--pmx-dialog-radius | var(--pmx-island-radius) |
--pmx-dialog-shadow | var(--pmx-shadow-xl) |
--pmx-dialog-backdrop | Black at 44% opacity |
Override tokens globally or on the surface itself:
.source-details {
--pmx-island-radius: 2rem;
--pmx-island-background: var(--pmx-color-surface);
--pmx-island-blur: 0px;
}
Rules have low specificity in the foundation layer, so ordinary CSS can also
override individual properties. Put global tokens on :root when they must
reach React portals. Browsers without backdrop blur and readers requesting
reduced transparency receive a solid theme surface.
Opt into progress styling with native HTML. The meter fills the available container width beside a compact count:
<div class="pmx-progress">
<progress value="2" max="3" aria-label="2 of 3 complete">2 of 3 complete</progress>
<span class="pmx-progress-count" aria-hidden="true">2/3</span>
</div>
.pmx-task-row aligns a task's status or checkbox with its content.
.pmx-task-status styles a read-only indicator; give it role="img", an
aria-label, and data-state="complete" or "incomplete". Use native
checkboxes for editable tasks. The owning plugin supplies actions and permissions.
| Variable | Default | Purpose |
|---|---|---|
--pmx-progress-meter-width | 100% | Meter width before making room for the count. Set a length such as 4rem for a compact meter. |
--pmx-progress-meter-height | .375rem | Meter thickness. |
--pmx-task-status-size | 1rem | Status and checkbox dimensions. |
--pmx-task-row-gap | .65rem | Space between status and task content. |
Colors use the existing accent, muted, border, and focus tokens. These styles apply only to opted-in elements. Completion uses them for progress and outlines; Path reuses the count styling for linked activities.
@pathmx/core/icons provides portable SVG geometry and rendering. Use icons
to inspect supported names or hasIcon(name) to check a value before rendering:
import { icons, hasIcon, renderIcon } from "@pathmx/core/icons"
const names = Object.keys(icons)
const svg = renderIcon("clipboard-list", { size: 20 })
renderIcon returns a decorative SVG string; give its surrounding button or
link an accessible name. An unknown string name returns undefined. In React,
use Icon from @pathmx/ui. Maps use the same
catalog for type icons.
The optional Settings UI is configured through Personal state and settings.