Target: Baseline 2024 — features available in all major browsers by end of 2024.
Desktop and iPad — on iOS, current Safari only. Popular iPad sizes are supported. Require the current Safari on iOS; users with older releases must upgrade. Phone layouts remain deferred. Ruled 2026-08-06.
Current iOS Safari supports CSS anchor positioning. The affected 2025 releases, before Safari 26, are outside the supported iOS range. Users on those releases must upgrade. Supporting iPad therefore does not require an additional positioning fix or change the polyfill approval requirement below.
Support touch interaction. An iPad normally has neither hover nor keyboard focus. Controls and tooltips available only on :hover are therefore inaccessible to touch users. Include @media (hover: none) behavior when designing them.
The application supports widths of 768 px and above. Narrower application layouts are outside its current support range.
768 px is the narrowest popular iPad in portrait (classic / 10.2″) and it is
48em — lg on Breakpoints, and the
app's only live breakpoint, below which the studio's content navigator overlays the reader instead
of sitting beside it (public/studio.html,
app-content-navigator.mjs). This matches the existing layout breakpoint. The iPad mini (744 px) is below the supported width; supporting it would require a separate decision.
Ruled 2026-08-06.
Newer CSS features are fine if guarded by @supports or if the degraded experience is acceptable.
Examples already in the codebase:
<x-select>, <ui-menu>, <ui-combobox>,
<ui-popover>, <ui-notifications> and
lib/tooltip.mjs. Engines without this feature discard every anchor() declaration, so the browser’s default popover placement applies and may cover the trigger. In a test without native support, an <x-select> had its trigger at
40,40 80×28 and the picker at 0,4 87×130, with
elementFromPoint() at the trigger's centre returning the option rather than the
button. Covered by ui/lib/anchor-fallback.mjs,
loaded from preload.mjs (see below): it measures the anchor when a popover opens
and writes the insets itself, placing the same picker at 40,72 87×130 —
matching Chromium’s native placement. Components need no additional code, and the module installs nothing on engines with native support. The fallback works only when
preload.mjs is the page's first module script — see § "Polyfills load from
preload.mjs" for the load-order requirement.position-try-fallbacks / @position-try — collision handling for
<ui-menu>: root menus flip above their trigger at the bottom edge, submenus flip to the
left at the right edge. All engines with anchor positioning evaluate the fallback list — measured
2026-09-04, the root picker flips along the block axis in Firefox 146 beta with geometry identical to
Chromium 145, retaining the same 0.25em gap as its preferred placement. This confirms that it flips sides rather than merely shifting into view. If a panel does not flip, check whether the engine is using the anchoring fallback below or the component has no fallback list. Supported engines differ when a box has too little room on both axes and none of its fallback options fits: one moves the box into view, while another returns it to the preferred side. Supply fallback positions that fit instead of adding browser-specific behavior. See Tooltip § "Placement". The
anchor-positioning fallback above does not reproduce general menu collision handling — a panel it places keeps its preferred position and may run past a viewport edge; it
anchors correctly but never flips or clamps, because checking overflow alone cannot distinguish clamping from flipping. Select pickers are the exception: their fallback prefers below the field, flips above when needed, and limits the scrollable picker to the available space. See Select placement. A box placed by position-area — the approach <ui-popover> and lib/tooltip.mjs use —
can take a fallback list too, and the tooltip does, but only while it declares no inset of its
own. Add a top/right/bottom/left
in the same axis as the position-area and the fallback list has no effect. For example, measured 2026-08-05 on Chromium 145, a 166px popover anchored
to a row at 1120..1280 in a 1280px viewport flipped to 951..1117 on its own,
and stayed at 1114..1280 once right: anchor(right) was added beside it.
An inset in the other axis does not suppress it. This is why <ui-menu>'s
submenus use anchor() insets plus named @position-try blocks rather than the
shorter position-area form: their inherited root-picker insets would otherwise prevent flipping.color-mix(in oklch, …) — Baseline 2024. Used for alpha composition./ui/ui.css.@supports (cx: 1) — feature detect SVG CSS attribute support for the sun-and-moon toggle (see Color).<ui-menu>, <ui-popover>, <ui-dialog>.<dialog> — Baseline 2022.CSS.highlights, new Highlight(),
::highlight()) — Chrome 105, Safari 17.2, Firefox 140, which makes it
Baseline 2025, a year later than the target above. An approved exception,
ruled 2026-09-13, for drawing a mark over a range of text without wrapping that range in an element. A view
that marks many short ranges — a reading position, a selection, search hits — would otherwise carry one
element per range, and the elements cost more than the marks they draw. Feature-detect
CSS.highlights and draw no mark where the API is missing: the text still reads and every other
way to reach it still works. ::highlight() accepts only the text-level properties
(color, background-color, the text-decoration family,
text-shadow), so a mark needing a border, padding or a box belongs on an element instead.
Reimplementing the API is a polyfill and needs approval of its own — see below.register(url, {type: 'module'})) — Firefox 147,
which makes them Baseline 2026, two years later than the target above. An older Firefox
runs a module worker as a classic script, and it fails on the first import. Write a service
worker as a classic script; see service worker.customElements.define — shipped for years.Navigator.getBattery), guard with a typeof check and provide a fallback.Types come from JSDoc annotations, checked in the IDE and optionally via tsc --noEmit. No build step needed to run the code.
Lit is allowed as a lightweight convenience for components with complex templates — not as a framework
commitment. Most components in this codebase extend HTMLElement directly via
UiElement.
preload.mjs, never from inside a componentNo polyfill may be added without the project owner's explicit approval. This applies to library and application changes, regardless of implementation cost. Present the need, alternatives, and costs before adding one.
Consider a polyfill only when a feature falls outside Baseline 2024 and neither an @supports/typeof guard with acceptable reduced functionality nor avoiding the feature meets the need. Guarding a feature and providing reduced functionality normally belongs in the component.
When one is warranted it is loaded in exactly one place:
public/preload.mjs, which runs ahead of app.mjs and all
component code and is where polyfill load order is controlled centrally. A component never carries its own
@supports/typeof branch that reimplements a missing feature — that spreads
the workaround across components, requiring separate removals when the feature enters the support target. Ruled 2026-08-04.
Load preload.mjs as the first module script on every page. A polyfill that reflects a property (anchor-name here, the ARIA attributes below) can handle only writes made after installation. Earlier writes have no effect. Component
modules claim their anchor names in connectedCallback, and
customElements.define() upgrades the static markup already in the page synchronously, so a
component listed above preload.mjs sets its anchor name before the shim can handle it. This produces no exception or console message. Browsers with native support remain unaffected; browsers needing the polyfill show the panel in the wrong position.
<link rel="modulepreload"> does not satisfy this; it fetches the module without evaluating it, so execution still waits for the first import. A repo-wide check
enforces the order, since no page can detect its own violation.
Load preload.mjs only through that script tag; never import it from a module.
It runs by itself, ahead of the rest of the page's modules. An import of it from an app entry
point or a component hides a page that is missing the script tag: the polyfills would install partway
through the module graph, after the modules imported before it had already run. The same check fails
on any .mjs that imports it.
One rejected candidate illustrates the implementation cost to consider. OddBird's css-anchor-positioning cannot read anchor() insets nested
inside :where(tag) { … }, which is the shape every public/ui/ component uses, so adopting it would require removing that nesting throughout the components. preload.mjs is the place to load a polyfill from,
and any required restructuring of component CSS must be included in the proposal for owner approval (2026-08-04). The approval requirement applies to every polyfill; the CSS changes explain the cost of this candidate. Anchored placement is covered instead by
ui/lib/anchor-fallback.mjs, which needs no CSS change.
An existing example:
ui/lib/aria-reflection.mjs adds ARIA
content-attribute reflection to Element.prototype for engines that lack it.
preload.mjs imports it unconditionally and the module feature-detects internally, doing nothing
where the platform already has the feature — so a current engine parses the module without changing its prototype. Remove a polyfill when its feature enters the project's Baseline support target. A single import makes removal straightforward and avoids conflicts between obsolete shims.
Not to be confused with the x- tag-name prefix (<x-select>), which marks a
component standing in for a native element rather than a runtime patch — see
Conventions. Pages opt into those components through markup; the runtime-polyfill rules in this section do not apply to them.