Exclusivobeta
Assist Mode <mv-assist-mode>
Un panel de asistencia para todo el sitio, sin juicios, para todo lo que depende del tiempo o del movimiento, ofrecido como ayuda opcional y no como un “modo de accesibilidad”. Los visitantes eligen un multiplicador de tiempo (1×, 1.5×, 2×, 4× o sin límites de tiempo), pausan la reproducción automática, reducen el movimiento y obtienen ventanas emergentes al pasar el cursor más estables, objetivos más grandes, apoyo a la lectura (espaciado de líneas, letras, palabras y párrafos) y un contraste más fuerte; cada ajuste es una línea en lenguaje claro con una pequeña vista previa en vivo que muestra su efecto. Las elecciones se escriben como atributos en <html> (o en una región de destino) que CSS y los scripts pueden leer: data-timing-assist + --mv-assist-time (ya respetado por timed-choice), la convención data-motion de la biblioteca, data-assist-autoplay (los videos, [autoplay] y los elementos [data-autoplay] como los carruseles se pausan de verdad, y solo se reanuda lo que se pausó), data-assist-hover, -targets (cada control construido sobre los tokens de tamaño crece hasta 44px), -reading y -contrast. Los ajustes sin modificar siguen al dispositivo (prefers-reduced-motion, prefers-contrast) en vivo y se etiquetan “From your device”; solo se guardan las elecciones reales (localStorage, sessionStorage o nada, siempre con protección), sincronizadas entre pestañas e instancias, y un restablecimiento siempre se anuncia y puede deshacerse. Cada cambio se emite como mv-assist-change con el estado completo; las exportaciones timing(ms) y hoverDelay(ms) y MvAssistMode.get() permiten que cualquier temporizador adopte el multiplicador en una línea. Dos variantes: un lanzador flotante con un diálogo no modal (o modal) y un panel en línea para una página de ajustes.
| Categoría | Utilidades |
|---|---|
| Tipo | Web Component (<mv-assist-mode>) |
| Estado | beta |
| También instala | button, switch, checkbox |
| Keywords | exclusive, culture, accessibility, a11y, preferences, settings, assist, timing, timing-adjustable, wcag-2-2-1, wcag-2-2-2, reduced-motion, pause-autoplay, target-size, text-spacing, contrast, hover-delay, persisted, multi-tab, utility |
When to use
- A site has toasts, countdowns, carousels or auto-advancing steps and must let people ask for more time or none (WCAG 2.2.1)
- Visitors need one place to pause auto-play, reduce motion and enlarge targets, remembered on their next visit
- An account settings page needs an Accessibility or Display section wired to real behavior, not just stored flags
- Several timed components should share one user preference instead of each offering its own slower option
Avoid when
- The only need is to cap or slow decorative animation, with no timing or reading preferences → use Motion Budget instead
- The site only needs a light and dark switch → use Theme Toggle instead
- It would replace fixing the page itself: assists add comfort but never make inaccessible markup, contrast or timing compliant
Instalación
node scripts/add.mjs assist-mode --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["assist-mode"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/observe.js, components/assist-mode/assist-mode.js, components/assist-mode/assist-mode.css, components/button/button.css, components/switch/switch.css, components/checkbox/checkbox.css.
Uso
Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="am-demo">
<style>
#am-demo { display:grid; grid-template-columns:minmax(0,1fr) 25rem; gap:1.25rem; align-items:start; width:min(100%,66rem); margin-inline:auto }
#am-demo .am-stage { position:relative; display:grid; gap:1rem; align-content:start; min-height:40rem; padding:0 0 7.75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#am-demo .am-bar { display:flex; align-items:center; gap:1rem; min-height:3.5rem; padding:.5rem 1rem .5rem 1.125rem; border-bottom:1px solid var(--mv-border); background:var(--mv-surface) }
#am-demo .am-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#am-demo .am-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#am-demo .am-logo svg { width:1rem; height:1rem }
#am-demo .am-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
#am-demo .am-nav a { display:block; padding:.375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
#am-demo .am-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
#am-demo .am-bar .mv-button { margin-inline-start:auto }
#am-demo .am-pad { display:grid; gap:1rem; padding:0 1.125rem }
#am-demo .am-slide { display:grid; align-content:end; gap:.125rem; height:100%; box-sizing:border-box; padding:1rem 4rem; border-radius:var(--mv-radius-lg); color:#fff }
#am-demo .am-slide small { font-size:.6875rem; letter-spacing:.08em; text-transform:uppercase; opacity:.85 }
#am-demo .am-slide strong { font-size:1.375rem; letter-spacing:-.02em }
#am-demo .am-slide span { font-size:.8125rem; opacity:.9 }
#am-demo .am-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem }
#am-demo .am-card { display:grid; gap:.625rem; align-content:start; min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#am-demo .am-card h4 { margin:0; font-size:.875rem; font-weight:600 }
#am-demo .am-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:var(--mv-leading-normal) }
#am-demo .am-hold { display:flex; align-items:baseline; justify-content:space-between; gap:.5rem }
#am-demo .am-fare { font-size:1.25rem; font-weight:650; letter-spacing:-.01em; font-variant-numeric:tabular-nums }
#am-demo .am-clock { color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#am-demo .am-clock b { color:var(--mv-fg); font-weight:600 }
#am-demo .am-actions { display:flex; flex-wrap:wrap; gap:.5rem }
#am-demo .am-ticker { position:relative; overflow:hidden; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); mask:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent) }
#am-demo .am-ticker-track { display:flex; gap:1.75rem; width:max-content; padding:.5rem 0; font:500 .75rem/1.2 var(--mv-font-mono); white-space:nowrap; animation:am-ticker 22s linear infinite }
#am-demo .am-ticker-track span b { color:var(--mv-success) }
#am-demo .am-ticker-track span i { font-style:normal; color:var(--mv-warning) }
@keyframes am-ticker { to { translate:-50% 0 } }
#am-demo .am-tip { position:relative; display:inline }
#am-demo .am-tip-trigger { padding:0; border:0; background:none; color:var(--mv-accent-fg); font:inherit; text-decoration:underline dotted; text-underline-offset:3px; cursor:help }
#am-demo .am-tip-card { position:absolute; z-index:5; left:0; bottom:calc(100% + .5rem); width:15rem; padding:.625rem .75rem; border:1px solid var(--mv-border-strong); border-radius:var(--mv-radius-md); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-lg); color:var(--mv-fg); font-size:.75rem; line-height:1.5 }
#am-demo .am-tip-card[hidden] { display:none }
#am-demo .am-toast { position:absolute; left:1.125rem; bottom:1.125rem; z-index:3; display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:.25rem .75rem; width:min(21rem,calc(100% - 9.5rem)); padding:.75rem .875rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-lg); overflow:hidden }
#am-demo .am-toast[hidden] { display:none }
#am-demo .am-toast-icon { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:50%; background:color-mix(in oklab,var(--mv-success) 16%,transparent); color:var(--mv-success) }
#am-demo .am-toast-icon svg { width:1rem; height:1rem }
#am-demo .am-toast strong { display:block; font-size:.8125rem }
#am-demo .am-toast span { display:block; color:var(--mv-fg-muted); font-size:.75rem }
#am-demo .am-toast-bar { position:absolute; left:0; right:0; bottom:0; height:3px; background:var(--mv-accent); transform-origin:left; animation:am-drain linear forwards }
#am-demo .am-toast-bar[hidden] { display:none }
@keyframes am-drain { from { scale:1 1 } to { scale:0 1 } }
#am-demo .am-side { display:grid; gap:.75rem }
#am-demo .am-log { margin:0; padding:.625rem .75rem; border:1px dashed var(--mv-border-strong); border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font:.6875rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
#am-demo .am-log b { color:var(--mv-fg); font-weight:600 }
@media (max-width:60rem) { #am-demo { grid-template-columns:minmax(0,1fr) } }
@media (max-width:36rem) { #am-demo .am-grid { grid-template-columns:minmax(0,1fr) } #am-demo .am-nav { display:none } }
</style>
<!-- A travel site whose timers, auto-play and targets all follow the assist settings -->
<section class="am-stage" id="am-stage" aria-label="Travel site preview">
<header class="am-bar">
<span class="am-brand">
<span class="am-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 15l7-2 5-8 2 1-3 7 5 1 2 3-8-1-5 5-2-1 2-4-5-1z"/></svg></span>
Wayline
</span>
<ul class="am-nav">
<li><a href="#" aria-current="page">Flights</a></li>
<li><a href="#">Stays</a></li>
<li><a href="#">Trips</a></li>
</ul>
<button type="button" class="mv-button" data-variant="outline" data-size="sm">Sign in</button>
</header>
<div class="am-pad">
<mv-carousel label="Featured trips" loop autoplay="3500" style="--mv-carousel-height:8.5rem">
<div class="am-slide" style="background:linear-gradient(135deg,#f0845c,#b14a6d 60%,#5b2a6e)"><small>Portugal · from $412</small><strong>Lisbon</strong><span>Trams, tiled facades and sunsets over the Tagus.</span></div>
<div class="am-slide" style="background:linear-gradient(160deg,#e45f7f,#7d3c98 55%,#2c2a6b)"><small>Japan · from $684</small><strong>Tokyo</strong><span>Late trains, quiet gardens and ramen at midnight.</span></div>
<div class="am-slide" style="background:linear-gradient(150deg,#1f9c9a,#1d5f8a 55%,#16294f)"><small>Canada · from $298</small><strong>Vancouver</strong><span>Mountains, sea walls and ferries to the islands.</span></div>
</mv-carousel>
<div class="am-grid">
<article class="am-card" aria-labelledby="am-hold-title">
<h4 id="am-hold-title">We’re holding this fare</h4>
<div class="am-hold">
<span class="am-fare">$684</span>
<span class="am-clock" id="am-clock" role="timer">Held for <b>4:00</b></span>
</div>
<p>Lisbon to Tokyo, Oct 14-28, 1 adult. The price is guaranteed until the hold ends.</p>
<div class="am-actions">
<button type="button" class="mv-button" data-size="sm">Book now</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="am-restart">Restart hold</button>
</div>
</article>
<article class="am-card" aria-labelledby="am-dep-title">
<h4 id="am-dep-title">Live departures</h4>
<div class="am-ticker" data-autoplay aria-hidden="true">
<div class="am-ticker-track">
<span>LIS → NRT 14:05 <b>On time</b></span><span>YVR → LHR 15:40 <i>Boarding</i></span><span>SFO → SIN 16:10 <b>On time</b></span><span>CDG → GRU 16:55 <i>Delayed</i></span>
<span>LIS → NRT 14:05 <b>On time</b></span><span>YVR → LHR 15:40 <i>Boarding</i></span><span>SFO → SIN 16:10 <b>On time</b></span><span>CDG → GRU 16:55 <i>Delayed</i></span>
</div>
</div>
<p>Carry-on bags up to 22 lb are included on every fare, plus one
<span class="am-tip">
<button type="button" class="am-tip-trigger" id="am-tip-trigger" aria-describedby="am-tip-card" aria-expanded="false">personal item</button>
<span class="am-tip-card" id="am-tip-card" role="tooltip" hidden>A bag that fits under the seat in front of you: 16 × 12 × 6 in. Laptop bags and small backpacks count.</span>
</span>.
</p>
<div class="am-actions">
<button type="button" class="mv-button" data-variant="secondary" data-size="sm" id="am-alert">Save price alert</button>
</div>
</article>
</div>
<article class="am-card" aria-labelledby="am-know-title">
<h4 id="am-know-title">Know before you go</h4>
<p>Japan lets US, Canadian and EU citizens visit for up to 90 days without a visa. Your passport must stay valid for the whole stay, and you may be asked for proof of onward travel at check-in.</p>
<p>Trains from Narita to central Tokyo take about an hour. The airport express leaves every 30 minutes and seats can be reserved when you book.</p>
</article>
</div>
<div class="am-toast" id="am-toast" role="status" hidden>
<span class="am-toast-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span>
<span><strong>Price alert saved</strong><span id="am-toast-text">We’ll email you if Lisbon to Tokyo drops.</span></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="am-toast-close">Dismiss</button>
<span class="am-toast-bar" id="am-toast-bar" aria-hidden="true"></span>
</div>
<!-- Floating variant, contained in the preview; it shares its settings with the inline panel -->
<mv-assist-mode id="am-floating" target="#am-stage" persist="none" storage-key="am-demo" contained></mv-assist-mode>
</section>
<div class="am-side">
<mv-assist-mode id="am-inline" variant="inline" target="#am-stage" persist="none" storage-key="am-demo" heading-level="3"></mv-assist-mode>
<p class="am-log" id="am-log" aria-live="off">mv-assist-change …</p>
</div>
<script type="module">
await customElements.whenDefined("mv-assist-mode");
const Assist = customElements.get("mv-assist-mode");
const root = document.getElementById("am-demo");
const stage = root.querySelector("#am-stage");
const inline = root.querySelector("#am-inline");
const log = root.querySelector("#am-log");
const $ = (id) => root.querySelector(`#${id}`);
// Fare hold: 2 minutes of real time, stretched by the multiplier.
const clock = $("am-clock");
let holdStart = Date.now();
const fmt = (ms) => { const t = Math.ceil(ms / 1000); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`; };
const renderHold = () => {
if (!clock.isConnected) return clearInterval(tick);
const total = Assist.timing(120_000, clock);
const b = document.createElement("b");
if (total === Infinity) { b.textContent = "until you decide"; clock.replaceChildren("Held ", b); return; }
b.textContent = fmt(Math.max(0, total - (Date.now() - holdStart)));
clock.replaceChildren("Held for ", b);
};
const tick = setInterval(renderHold, 1000);
$("am-restart").addEventListener("click", () => { holdStart = Date.now(); renderHold(); });
// Price alert toast: closes after 5 s × the multiplier, or never with no limits.
const toast = $("am-toast"), bar = $("am-toast-bar");
let hideTimer = 0;
const showToast = () => {
clearTimeout(hideTimer);
const ms = Assist.timing(5000, toast);
toast.hidden = false;
bar.hidden = ms === Infinity;
bar.style.animation = "none"; void bar.offsetWidth; bar.style.animation = "";
bar.style.animationDuration = `${ms}ms`;
if (ms !== Infinity) hideTimer = setTimeout(() => { toast.hidden = true; }, ms);
};
$("am-alert").addEventListener("click", showToast);
$("am-toast-close").addEventListener("click", () => { clearTimeout(hideTimer); toast.hidden = true; });
// Hover card: longer open delay and sticky with “Steadier hover”.
const trigger = $("am-tip-trigger"), card = $("am-tip-card"), tip = trigger.parentElement;
let openTimer = 0, closeTimer = 0;
const setOpen = (on) => { card.hidden = !on; trigger.setAttribute("aria-expanded", String(on)); };
const steady = () => Boolean(trigger.closest("[data-assist-hover]"));
tip.addEventListener("pointerenter", () => { clearTimeout(closeTimer); openTimer = setTimeout(() => setOpen(true), Assist.hoverDelay(300, trigger)); });
tip.addEventListener("pointerleave", () => { clearTimeout(openTimer); closeTimer = setTimeout(() => setOpen(false), steady() ? 700 : 0); });
trigger.addEventListener("focus", () => setOpen(true));
trigger.addEventListener("blur", () => { if (!steady()) setOpen(false); });
trigger.addEventListener("click", () => setOpen(card.hidden));
tip.addEventListener("keydown", (e) => { if (e.key === "Escape" && !card.hidden) { e.stopPropagation(); setOpen(false); } });
// What other scripts hear.
root.addEventListener("mv-assist-change", (e) => {
const { state, source, changed } = e.detail;
const on = Object.entries(state).filter(([k, v]) => (k === "time" ? v !== 1 : v)).map(([k, v]) => (k === "time" ? `time ${v === Infinity ? "∞" : v + "×"}` : k));
log.replaceChildren(Object.assign(document.createElement("b"), { textContent: "mv-assist-change" }), ` · ${source}${changed.length ? ` (${changed.join(", ")})` : ""} · ${on.join(", ") || "all off"}`);
renderHold();
});
// Start with two assists on so the effect is visible right away.
inline.set({ time: 2, targets: true });
renderHold();
showToast();
</script>
</div>Referencia cultural
Celeste, Maddy Makes Games (Maddy Thorson & Noel Berry) (2018, videojuego). Un juego de plataformas exigente ofrece, sin ningún juicio, un menú de asistencia donde cualquier jugador puede ralentizar el juego, obtener ayuda extra o volverse invencible, para que todos puedan llegar al final. En la interfaz, un panel de asistencia para todo el sitio permite a cualquiera alargar o eliminar los límites de tiempo, pausar la reproducción automática, calmar el movimiento y agrandar los objetivos, y cada componente temporizado de la página sigue esa elección.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
variant | floating | inline | floating | floating: a pill launcher (“Assist”, with the number of assists on) pinned to a corner that opens the panel in a <dialog>. inline: the panel itself, in the flow (settings page). Mirrored as data-variant. |
target | CSS selector | html | Element that receives the assist attributes. Leave it empty for the whole site; a region (a preview, an embedded app) is possible, but JavaScript components that read reduced motion from <html> only follow a page-wide target. |
persist | local | session | none | local | Where the user's choices are kept: localStorage (synced across tabs), sessionStorage, or nowhere. Storage errors are caught; if the browser refuses, the footer says the settings last until the visitor leaves. |
storage-key | string | mv-assist | Storage key, also used to keep several instances on one page (a floating launcher and a settings page) in sync. |
features | space- or comma-separated list: time, autoplay, motion, hover, targets, reading, contrast | all | Settings to offer. Settings left out are neither shown nor applied. |
times | list of multipliers, “none” = no limits | 1 1.5 2 4 none | Steps of the time multiplier (1× is always included). A value set through set() that is not listed gets its own step. |
placement | bottom-end | bottom-start | top-end | top-start | bottom-end | Corner of the floating launcher; the panel opens next to it. |
contained | boolean | Pins the launcher and panel to the nearest positioned ancestor instead of the viewport (previews, app regions). | |
modal | boolean | Opens the panel with showModal(): centered, backdrop, focus contained by the browser. By default the dialog is non-modal so the page stays visible and usable while settings change. | |
compact | boolean | Icon-only launcher (the count moves to a corner badge); its accessible name stays “Assist”, plus the count. | |
open | boolean | Floating variant: the panel is open. Reflects show(), close() and the launcher. | |
heading-level | 1-6 | 2 | Level of the panel heading, to fit the page outline. |
data-active | set by the component | Present on the host while at least one assist is on. |
Properties
| Name | Tipo | Description |
|---|---|---|
state | { time, autoplay, motion, hover, targets, reading, contrast } | Settings in effect (copy). time is a multiplier, Infinity for no limits; the others are booleans. |
sources | Record<key, "user" | "device" | "default"> | Where each value comes from. device: pre-filled from prefers-reduced-motion (motion and auto-play) or prefers-contrast: more, and still following it live. |
explicit | Partial<state> | Only the values the user changed: what is stored. |
saved | boolean | False when the browser refused to store the last change. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (title, intro, launcher, section and row titles and texts, time effect lines with {time}, footer, reset/undo, announcements). English defaults. |
MvAssistMode.get() | static | State of the first connected instance, or the stored settings merged with the device preferences when none is on the page. For scripts that don't want to listen to events. |
MvAssistMode.set(partial) | static | Calls set() on the first connected instance; false when there is none. |
MvAssistMode.restore({ storageKey?, persist?, target?, features? }) | static | Applies the stored settings immediately, before any instance exists (early in the page, to avoid a flash of motion on load). Returns the state. |
MvAssistMode.timing / MvAssistMode.hoverDelay | static | Same as the timing() and hoverDelay() exports. |
Methods
| Name | Description |
|---|---|
set(partial) | Applies settings as a user choice (stored, broadcast with source "api"), e.g. set({ time: 2, targets: true }); time accepts a number, "none" or Infinity. Unknown keys and invalid values are ignored. Returns the new state. |
reset() | Forgets every user choice so all settings follow the device again, announces it and turns the footer button into “Undo reset”. Returns false if there was nothing to reset. |
undo() | Restores the choices cleared by the last reset (until another change is made). |
show() / close() / toggle(force?) | Floating variant: opens or closes the panel. Opening focuses the checked time step (or the first control); closing returns focus to the launcher if it was inside. |
timing(duration, el?) | Named export. duration × the multiplier in effect for el (closest [data-timing-assist], else <html>); Infinity for no time limits, so skip scheduling the timer. SSR-safe. |
hoverDelay(delay, el?) | Named export. delay, or max(delay × 2.5, delay + 600) under “Steadier hover”; such hover content should also stay open until the pointer leaves both trigger and content, or Escape. |
Events
| Name | Description |
|---|---|
mv-assist-change | Bubbles to document on every effective change. detail: { state, sources, explicit, changed (keys), source: "init" | "user" | "api" | "device" | "sync" | "reset" | "undo", key, target }. Fired once with source "init" on connection, so listeners can adopt the initial state. |
mv-assist-open / mv-assist-close | Floating variant: the panel opened or closed. |
CSS classes
| Name | Description |
|---|---|
mv-assist-mode-panel | The settings panel (<section> labelled by its heading): -head, -title, -close, -intro, -section (role=group) with -section-title, -rows, -foot, -status (-count, -saved), -reset. |
mv-assist-mode-row | One setting: .mv-assist-mode-preview (data-preview=time | autoplay | motion | hover | targets | reading | contrast), -row-title, -origin (“From your device”), -row-desc and the switch. data-key, data-on. |
mv-assist-mode-time / -steps / -step / -effect | The time multiplier: a role=radiogroup of native radios drawn as a segmented control, and the line that spells out its effect. data-unlimited with no limits. |
mv-assist-mode-launcher / -launcher-count / -dialog | Floating variant: the pill button (aria-haspopup=dialog, aria-expanded), its count badge and the <dialog> (data-modal with modal). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-assist-time | 1 | Set on the target: the time multiplier (1000 for no limits). Scale CSS timers with it: animation-duration: calc(5s * var(--mv-assist-time, 1)). |
--mv-assist-hover-scale | 1 (2.5 when steady) | Set on the target: scale CSS hover delays with it. |
--mv-assist-leading / --mv-assist-letter-spacing / --mv-assist-word-spacing / --mv-assist-paragraph-spacing | 1.8 / 0.03em / 0.12em / 1em | Reading support values (define them on the target to tune). |
--mv-assist-mode-accent | var(--mv-accent) | Tint of active previews, the launcher icon and its count. |
--mv-assist-mode-offset | 1rem | Distance of the floating launcher and panel from the corner. |
--mv-assist-mode-width | 26rem | Maximum width of the inline panel. |
Accessibility
Everything is built from native controls: each on/off setting is an <input type="checkbox" role="switch"> named by its title and described by its one-line explanation (the whole row is its <label>, so the hit area is the full row), and the time multiplier is a role="radiogroup" of native radios (arrow keys, one tab stop) described by the line that spells out its effect in words (“A message that closes after 5 seconds now stays 10 seconds.”), never by the preview alone. Each group of settings is a labelled role="group". Previews are aria-hidden and pointer-events: none. Values that come from the device are marked in text (“From your device”), not only by state. The floating launcher is a real <button> with aria-haspopup="dialog", aria-expanded and aria-controls, whose name starts with its visible label (“Assist, 2 on”). The panel is a <dialog> labelled by its heading: opening moves focus to the checked time step (or the first control), Escape or the Close button closes it and returns focus to the launcher; it is non-modal by default so the page can be checked while settings change, and modal uses showModal() for full focus containment. Nothing is ever reset silently: a stored entry that cannot be read is ignored but never overwritten until the user changes something, Reset all is announced in a polite live region and becomes Undo reset, a change from another tab is announced, and a browser that refuses to store settings is announced once and stated in the footer. Settings apply immediately (no Save step), and the component's own previews stop under prefers-reduced-motion or data-motion="reduce". Forced colors: previews use CanvasText and Highlight, the checked step gets a Highlight outline.