Shared UI

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.

Shared controls

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.

TokenDefaultControls
--pmx-control-size2.5remRegular height and icon-action width.
--pmx-control-size-sm2.25remCompact desktop size; touch targets remain at least 44px.
--pmx-control-radiusvar(--pmx-radius-full)Button corners.
--pmx-control-icon-size1.2remButton icon dimensions.
--pmx-control-icon-stroke1.75Button icon stroke weight.
--pmx-motion-duration160msShared state-transition duration.
--pmx-motion-easeease-outShared 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.

Form fields

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.

Touch and mobile controls

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.

Native dialogs

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.

Add a React dialog

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.

Mount and try the dialog

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.

Split actions

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.

Floating islands and dialogs

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.

VariableDefault
--pmx-island-backgroundTheme surface at 84% opacity
--pmx-island-bordervar(--pmx-color-border)
--pmx-island-radius1.75rem
--pmx-island-shadowvar(--pmx-shadow-lg)
--pmx-island-blur20px
--pmx-dialog-radiusvar(--pmx-island-radius)
--pmx-dialog-shadowvar(--pmx-shadow-xl)
--pmx-dialog-backdropBlack 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.

Progress and task status

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.

VariableDefaultPurpose
--pmx-progress-meter-width100%Meter width before making room for the count. Set a length such as 4rem for a compact meter.
--pmx-progress-meter-height.375remMeter thickness.
--pmx-task-status-size1remStatus and checkbox dimensions.
--pmx-task-row-gap.65remSpace 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.

Icon catalog

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