Exclusifbêta
Invite In <mv-invite-in>
Intégration respectueuse de la vie privée qui n’envoie aucune requête à un tiers tant que l’utilisateur ne l’a pas invitée : vidéos YouTube et Vimeo, cartes, publications de réseaux sociaux, widgets de chat, iframes chargées de traceurs, feuilles de style de polices tierces. Avant le consentement, l’intégration n’existe que sous forme d’attribut ou de <template> inerte, jamais sous forme d’iframe ou de script actif ; le placeholder conserve le ratio final, affiche l’image d’aperçu de l’app (ou un aperçu neutre avec fournisseur, titre et détails), dit en une ligne ce que le chargement partage (« Charger cette vidéo partage votre adresse IP et vos cookies avec YouTube. »), avec la politique de confidentialité du fournisseur et une porte de sortie « Ouvrir sur YouTube », et propose « Charger la vidéo » (celle-ci, en lecture automatique) ou « Toujours autoriser YouTube ». Le consentement mémorisé est propre à chaque fournisseur (à chaque hôte pour les iframes simples), stocké uniquement si l’utilisateur le choisit (localStorage, session ou cette page), synchronisé entre onglets et révocable depuis MvInviteIn.providers() ou la liste compagnon <mv-invite-in-settings> : les fournisseurs autorisés se chargent en différé à l’approche du viewport, les fournisseurs révoqués sont déconnectés sur-le-champ. Des presets réécrivent YouTube vers youtube-nocookie.com et Vimeo avec dnt=1, analysent les URL watch, short, embed et playlist, construisent des intégrations Google Maps à partir d’une simple adresse, et les fournisseurs personnalisés s’enregistrent en un appel ; mv-invite est annulable pour journaliser le consentement ou brancher votre propre CMP, et les scripts de template sont recréés (nonce conservé) pour que les widgets ne s’exécutent qu’une fois invités.
| Catégorie | Médias |
|---|---|
| Type | Web Component (<mv-invite-in>) |
| Statut | bêta |
| Kit | Confiance et vie privée |
| Installe aussi | button, switch, checkbox |
| Keywords | exclusive, culture, privacy, consent, gdpr, embed, iframe, youtube, vimeo, maps, facade, lazy-load, click-to-load, third-party, cookies, performance, chat-widget, cmp |
When to use
- A marketing page, blog or course embeds YouTube, Vimeo or map iframes and must not contact those services before consent
- A chat, social or booking widget should load its third-party script only when a visitor actually wants it
- Visitors should be able to trust a provider once and later withdraw that choice from a settings or privacy page
- A page full of video embeds loads slowly and each player should cost nothing until someone asks for it
Avoid when
- You need a full cookie banner with purposes and vendor lists: this handles consent per embed, not site-wide categories
- The media is self-hosted and contacts no third party: a native video element or a lazy iframe needs no consent gate
- Sensitive first-party data must be revealed with an audit trail rather than a third party being loaded → use Tamper Seal instead
Installation
node scripts/add.mjs invite-in --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["invite-in"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/invite-in/invite-in.js, components/invite-in/invite-in.css, components/button/button.css, components/switch/switch.css, components/checkbox/checkbox.css.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-invite-in src="https://www.youtube.com/watch?v=WhWc3b3KhnY" label="Product tour"></mv-invite-in>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<div id="ii-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#ii-demo { display:grid; gap:1rem; align-content:start }
#ii-demo .ii-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; padding:1rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#ii-demo .ii-title { display:grid; gap:.25rem; min-width:0 }
#ii-demo .ii-crumbs { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#ii-demo .ii-title h3 { margin:0; font-size:1.125rem; letter-spacing:-.015em }
#ii-demo .ii-by { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; color:var(--mv-fg-muted); font-size:.8125rem }
#ii-demo .ii-controls { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
#ii-demo label.mv-choice { font-size:.8125rem }
#ii-demo .ii-grid { display:grid; grid-template-columns:minmax(0,1fr) 20rem; gap:1rem; align-items:start }
#ii-demo .ii-main { display:grid; gap:1rem; min-width:0 }
#ii-demo .ii-text { margin:0; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.6; max-width:62ch }
#ii-demo .ii-text strong { color:var(--mv-fg); font-weight:600 }
#ii-demo .ii-pair { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1rem; align-items:start }
#ii-demo figure { display:grid; gap:.5rem; margin:0; min-width:0 }
#ii-demo figcaption { color:var(--mv-fg-muted); font-size:.75rem }
#ii-demo .ii-side { display:grid; gap:1rem; min-width:0 }
#ii-demo .ii-card { display:grid; gap:.75rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs); min-width:0 }
#ii-demo .ii-card h4 { margin:0; font-size:.875rem; letter-spacing:-.01em }
#ii-demo .ii-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#ii-demo .ii-card-head { display:grid; gap:.25rem }
#ii-demo .ii-status { display:flex; align-items:center; justify-content:space-between; gap:.5rem }
#ii-demo .ii-log { display:grid; gap:.375rem; margin:0; padding:0; list-style:none; font:.75rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#ii-demo .ii-log li { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.5rem }
#ii-demo .ii-log time { color:var(--mv-fg-subtle) }
#ii-demo .ii-log b { color:var(--mv-fg); font-weight:600 }
/* The mentor chat below is a stand-in for a third-party widget script. */
#ii-demo .ii-chat { display:grid; grid-template-rows:auto 1fr auto; min-height:15rem; background:var(--mv-surface) }
#ii-demo .ii-chat-head { display:flex; align-items:center; gap:.625rem; padding:.75rem .875rem; border-bottom:1px solid var(--mv-border) }
#ii-demo .ii-chat-avatar { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-accent); color:var(--mv-fg-on-accent); font-size:.75rem; font-weight:600 }
#ii-demo .ii-chat-who { display:grid; line-height:1.3 }
#ii-demo .ii-chat-who b { font-size:.8125rem }
#ii-demo .ii-chat-who span { color:var(--mv-fg-muted); font-size:.75rem }
#ii-demo .ii-chat-body { display:grid; align-content:end; gap:.5rem; padding:.875rem }
#ii-demo .ii-chat-msg { justify-self:start; max-width:85%; padding:.5rem .75rem; border-radius:var(--mv-radius-lg); border-end-start-radius:.25rem; background:var(--mv-bg-muted); font-size:.8125rem; line-height:1.45 }
#ii-demo .ii-chat-form { display:flex; gap:.5rem; padding:.625rem .75rem; border-top:1px solid var(--mv-border) }
#ii-demo .ii-chat-form input { flex:1; min-width:0; height:2rem; padding:0 .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-bg); color:var(--mv-fg); font:inherit; font-size:.8125rem }
@media (max-width:60rem) { #ii-demo .ii-grid { grid-template-columns:minmax(0,1fr) } }
@media (max-width:40rem) { #ii-demo .ii-pair { grid-template-columns:minmax(0,1fr) } }
</style>
<header class="ii-head">
<span class="ii-title">
<span class="ii-crumbs">Northlight Academy · Lighting for Animation</span>
<h3>Lesson 3 · Rim light and silhouettes</h3>
<span class="ii-by"><span class="mv-badge" data-variant="secondary" data-size="sm">Lesson 3 of 8</span> Amara Okafor · 25 min</span>
</span>
<span class="ii-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ii-persist" checked> Remember choices across visits</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ii-reset">Reset demo</button>
</span>
</header>
<div class="ii-grid">
<div class="ii-main">
<!-- YouTube (rewritten to youtube-nocookie.com), with a poster supplied by the app -->
<mv-invite-in id="ii-video" src="https://www.youtube.com/watch?v=WhWc3b3KhnY"
label="Spring, a Blender open movie" subtitle="Blender Studio · CC BY 4.0"
poster="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900' preserveAspectRatio='xMidYMid slice'%3E%3Cdefs%3E%3ClinearGradient id='s' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%231b3347'/%3E%3Cstop offset='.5' stop-color='%2358808f'/%3E%3Cstop offset='1' stop-color='%23e9c7a2'/%3E%3C/linearGradient%3E%3CradialGradient id='g' cx='.7' cy='.56' r='.4'%3E%3Cstop offset='0' stop-color='%23ffe9c7' stop-opacity='.9'/%3E%3Cstop offset='1' stop-color='%23ffe9c7' stop-opacity='0'/%3E%3C/radialGradient%3E%3ClinearGradient id='f' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%23fff' stop-opacity='.35'/%3E%3Cstop offset='1' stop-color='%23fff' stop-opacity='0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='1600' height='900' fill='url(%23s)'/%3E%3Crect width='1600' height='900' fill='url(%23g)'/%3E%3Cpath d='M0 540 L170 455 L330 505 L520 370 L700 465 L860 395 L1040 490 L1230 360 L1420 455 L1600 405 V900 H0Z' fill='%2348707e' opacity='.7'/%3E%3Crect y='470' width='1600' height='160' fill='url(%23f)' opacity='.5'/%3E%3Cpath d='M0 650 L140 585 L300 632 L480 548 L680 625 L870 560 L1070 645 L1250 572 L1430 636 L1600 590 V900 H0Z' fill='%232a4855'/%3E%3Cpath d='M0 765 C200 722 380 786 600 742 C820 700 1000 772 1220 732 C1380 706 1500 742 1600 726 V900 H0Z' fill='%23132a34'/%3E%3C/svg%3E">
<a href="https://www.youtube.com/watch?v=WhWc3b3KhnY">Watch “Spring” on YouTube</a>
</mv-invite-in>
<p class="ii-text">Pause on the forest shots: the <strong>rim light</strong> traces the character's outline against a darker background, so the silhouette reads even when the key light is low. Compare with the soft morning key light in the second reference.</p>
<div class="ii-pair">
<figure>
<!-- Vimeo (dnt=1), neutral preview: no poster -->
<mv-invite-in src="https://vimeo.com/1084537" label="Big Buck Bunny" subtitle="9:57 · Blender Foundation · CC BY 3.0"></mv-invite-in>
<figcaption>Reference 2 · Soft morning key light</figcaption>
</figure>
<figure>
<!-- Custom provider + <template>: the widget script only runs once invited -->
<mv-invite-in provider="helpwise" ratio="auto" label="Ask a mentor" subtitle="Amara usually replies within 10 minutes" style="--mv-invite-in-min-height:15rem">
<template>
<div class="ii-chat"></div>
<script>
(() => {
const root = document.currentScript.previousElementSibling;
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
const head = el("div", "ii-chat-head");
const who = el("span", "ii-chat-who");
who.append(el("b", "", "Amara Okafor"), el("span", "", "Mentor · online"));
head.append(el("span", "ii-chat-avatar", "AO"), who);
const body = el("div", "ii-chat-body");
body.append(el("p", "ii-chat-msg", "Hi! Stuck on the rim light exercise? Share a frame and I'll take a look."));
const form = el("form", "ii-chat-form");
const input = el("input");
input.placeholder = "Write a message…";
input.setAttribute("aria-label", "Message to Amara");
const send = el("button", "mv-button", "Send");
send.dataset.size = "sm";
form.append(input, send);
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!input.value.trim()) return;
const mine = el("p", "ii-chat-msg", input.value.trim());
mine.style.cssText = "justify-self:end;background:var(--mv-accent);color:var(--mv-fg-on-accent);border-radius:var(--mv-radius-lg);border-end-end-radius:.25rem";
body.append(mine);
input.value = "";
});
root.append(head, body, form);
})();
</script>
</template>
</mv-invite-in>
<figcaption>Live help · third-party chat widget</figcaption>
</figure>
</div>
</div>
<aside class="ii-side">
<section class="ii-card" aria-labelledby="ii-venue-title">
<span class="ii-card-head">
<h4 id="ii-venue-title">In-person session</h4>
<p>Thursday, Oct 8 · 6:00 PM PDT · Pier 70, San Francisco</p>
</span>
<!-- Google Maps from a plain address -->
<mv-invite-in provider="google-maps" ratio="5/4" src="Pier 70, San Francisco, CA" label="Pier 70, San Francisco" subtitle="420 22nd St, San Francisco, CA 94107"></mv-invite-in>
</section>
<section class="ii-card" aria-labelledby="ii-settings-title">
<span class="ii-card-head">
<h4 id="ii-settings-title">Embedded content</h4>
<p>Services allowed to load without asking. Switch one off to disconnect it.</p>
</span>
<mv-invite-in-settings id="ii-settings"></mv-invite-in-settings>
</section>
<section class="ii-card" aria-labelledby="ii-log-title">
<span class="ii-status">
<h4 id="ii-log-title">Consent log</h4>
<span class="mv-badge" data-variant="success" data-size="sm" id="ii-count">0 connected</span>
</span>
<ol class="ii-log" id="ii-log" aria-live="polite"></ol>
</section>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-invite-in");
const MvInviteIn = customElements.get("mv-invite-in");
const root = document.getElementById("ii-demo");
const log = document.getElementById("ii-log");
const count = document.getElementById("ii-count");
const persist = document.getElementById("ii-persist");
// A custom provider for a script-based widget (its markup lives in the element's <template>).
MvInviteIn.register("helpwise", {
name: "Helpwise",
kind: "chat",
note: "Loading chat shares your IP address and browser details with Helpwise.",
});
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const add = (who, what) => {
const li = document.createElement("li");
const t = document.createElement("time");
t.textContent = time();
const text = document.createElement("span");
if (who) { const b = document.createElement("b"); b.textContent = who; text.append(b, " "); }
text.append(what);
li.append(t, text);
log.prepend(li);
while (log.children.length > 6) log.lastElementChild.remove();
};
const connected = new Set();
const render = () => {
count.textContent = `${connected.size} connected`;
count.dataset.variant = connected.size ? "warning" : "success";
};
add("", "Page ready. No request made to YouTube, Vimeo, Google or Helpwise.");
const how = { click: "loaded once", always: "always allowed", remembered: "loaded from a saved choice", settings: "allowed in settings", api: "loaded by the app" };
root.addEventListener("mv-invite", (e) => add(e.detail.name, how[e.detail.reason] + (e.detail.scope === "session" ? " (this visit)" : "")));
root.addEventListener("mv-revoke", (e) => add(e.detail.name, "revoked"));
root.addEventListener("mv-load", (e) => { connected.add(e.target); render(); });
root.addEventListener("mv-unload", (e) => { connected.delete(e.target); render(); add(e.detail.name, "disconnected"); });
const embeds = () => root.querySelectorAll("mv-invite-in");
persist.addEventListener("change", () => {
for (const el of embeds()) el.remember = persist.checked ? "local" : "session";
document.getElementById("ii-settings").scope = persist.checked ? "local" : "session";
});
document.getElementById("ii-reset").addEventListener("click", () => {
MvInviteIn.revoke(null, { source: "silent" });
for (const el of embeds()) el.unload();
log.replaceChildren();
add("", "Demo reset. Every service asks again.");
});
</script>
</div>Référence culturelle
Dracula, Bram Stoker (1897, livre). Dans la mythologie du vampire popularisée par le roman, le vampire ne peut franchir un seuil tant que quelqu’un à l’intérieur ne l’a pas invité à entrer, et une fois invité, il peut revenir à sa guise. Dans l’interface, un contenu intégré tiers attend à l’extérieur de la page, sans émettre la moindre requête, jusqu’à ce que l’utilisateur l’invite, et « Always allow » permet à ce fournisseur de revenir sur chaque page jusqu’au retrait de l’invitation.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
provider | youtube | vimeo | google-maps (alias maps) | iframe | custom id | Provider preset. Detected from the src host when omitted (youtube.com, youtu.be, vimeo.com, google.com/maps), otherwise a generic iframe whose consent is keyed per host (iframe:calendar.example.com), so allowing one site never allows another. | |
src | string | What to embed. YouTube: watch, youtu.be, shorts, embed or playlist URL (t= / start= kept) or a bare 11-character id. Vimeo: page or player URL (unlisted hash kept) or numeric id. Google Maps: an embed URL, a maps URL with q=, or a plain address. iframe: any http(s) URL. Only http(s) ever reaches the iframe. Ignored when a <template> child is present. | |
label | string | Title of the content (“Product tour”). Shown on the placeholder, used in the button name (“Load video: Product tour, from YouTube”) and in the loaded iframe's title. | |
subtitle | string | Secondary line under the title: duration, author, license, address… | |
poster | URL | Preview image supplied by your app (self-hosted: the component never fetches provider thumbnails, which would leak the visit). A child with slot="poster" (<img>, <picture>) works too. Without one, a neutral preview is drawn. | |
ratio | "16/9" | "4:3" | number | "auto" | provider preset (16/9, maps 4/3) | Aspect ratio kept before and after loading, so nothing shifts. auto = natural height of the loaded content (chat widgets, posts), with --mv-invite-in-min-height for the placeholder. |
kind | string | provider preset | Noun used in the texts and icon: video, audio, map, post, chat, content (“Load map”, “Loading this map shares…”). |
remember | local | session | memory | off | local | Where “Always allow” stores the choice: this browser (“Always allow YouTube”), this visit (“Allow YouTube for this visit”), this page only (“Allow YouTube on this page”), or off to hide the option. Nothing is stored until the user chooses it; blocked storage falls back to memory. |
autoplay | "true" | "false" | true | Adds autoplay when the user clicks to load (the click already expressed intent). Never applied to embeds loaded from a remembered choice. |
lazy | "true" | "false" | true | Embeds of an allowed provider load when they come within 240px of the viewport instead of all at once. |
params | query string | Extra parameters set on the final embed URL ("cc_load_policy=1&hl=en", "z=13"). | |
note | string | Overrides the one-line privacy note ({name} and {kind} are replaced). | |
policy | URL | provider preset | Privacy policy linked under the note (YouTube and Google Maps: policies.google.com, Vimeo: vimeo.com/privacy). Hidden when none. |
allow / sandbox | string | Override the iframe's allow list and add a sandbox. | |
credentialless | boolean | Loads the iframe as credentialless where supported (Chromium): no cookies or storage shared with the provider's other sites. | |
data-state | waiting | loading | loaded | Set by the component (styleable), with data-provider, data-kind, data-poster and data-ratio="auto". aria-busy while loading. |
Properties
| Name | Type | Default | Description |
|---|---|---|---|
state | "waiting" | "loading" | "loaded" | Read-only. | |
url | string | null | The embed URL loading will request, before the autoplay flag a click adds (null for <template> content or an invalid src). Read-only. | |
consentKey / providerName / kindNoun | string | Resolved consent key (youtube, iframe:host…), display name and kind. Read-only. | |
allowed | boolean | Whether this embed's provider is currently remembered as allowed. Read-only. | |
strings | Partial<Record<string, string>> | Overrides for every visible text (keys: load, loadLabel, loadLabelNoTitle, loading, idle, note, always, allowSession, allowPage, policy, open, frameTitle, frameTitleNoLabel, newTab, announceAllowed, announceDisconnected). English defaults; {kind}, {name}, {label} are replaced. | |
MvInviteIn.storageKey | string | "mv-invite-in" | Static: storage key used for remembered choices. |
Methods
| Name | Description |
|---|---|
invite({ remember? }) | Loads the embed from code (reason "api"); remember also grants the provider. Emits mv-invite first. Returns false if canceled or nothing to load. |
unload() | Disconnects the embed (the iframe or widget is removed) and shows the placeholder again; focus returns to it if it was inside. Emits mv-unload. |
MvInviteIn.providers() | Static. [{ id, name, kind, policy, allowed, scope, since (Date), inUse }] for every preset, custom provider, remembered host and host on the page: build your own privacy settings from it. |
MvInviteIn.allow(id, { scope? }) / MvInviteIn.revoke(id?) / MvInviteIn.isAllowed(id) | Static. Grant from your own consent banner (all matching embeds then load), withdraw one provider or all of them (matching embeds are disconnected, mv-revoke fires on document with source "api"), or check. |
MvInviteIn.register(id, def) | Static. Adds or overrides a provider: { name, kind, ratio, hosts: ["example.com"], match(url), embed(src, { autoplay }) → URL, open(src) → URL, note, policy, allow, sandbox }. Without embed, the element's <template> is used. Instances already on the page re-render. |
Events
| Name | Description |
|---|---|
mv-invite | Cancelable, before anything is requested. detail: { provider, name, kind, url, reason: "click" | "always" | "remembered" | "settings" | "api", remember, scope }. Log consent here, or preventDefault() to route through your own consent platform. Also fired by <mv-invite-in-settings> when a provider is switched on. |
mv-revoke | Consent withdrawn. detail: { provider, name, source: "settings" | "api" | "storage" }. Fired by <mv-invite-in-settings>, or on document for MvInviteIn.revoke() and changes made in another tab (storage). |
mv-load | The embed finished loading (or 8 s passed). detail: { provider, name, url }. |
mv-unload | The embed was disconnected (unload() or revocation). detail: { provider, name }. |
Content structure
| Name | Description |
|---|---|
poster | An <img> or <picture> used as the preview (moved into the placeholder, decorative). |
<template> | Markup of script-based embeds (social post, chat widget, font <link>…). Inert until invited, then cloned in; scripts are re-created in order, nonce kept. |
(fallback) | Other children (e.g. a plain “Watch on YouTube” link) show before the element upgrades or without JavaScript, then are hidden. |
CSS classes
| Name | Description |
|---|---|
mv-invite-in-stage | The ratio box holding the placeholder (.mv-invite-in-cover) and the loaded embed (.mv-invite-in-content, iframe.mv-invite-in-frame). |
mv-invite-in-load | The placeholder's real <button>: -chip (provider · Not connected), -center, -icon, -action, -title, -subtitle. |
mv-invite-in-foot | Privacy footer: -shield, -note (aria-describedby target), -links (-link: policy, open on provider), -always (the remembered choice). |
mv-invite-in-settings-* | <mv-invite-in-settings>: -list, -item (data-allowed), -row (label), -glyph, -name, -status, -empty, -foot, -note, -forget. Attributes: providers (comma list, default: those on the page then the remembered ones), label (list name, default “Embedded content”), scope (where switching on stores: local | session | memory). strings property: label, switchLabel, ask, allowedLocal, allowedSession, allowedMemory, inUse, empty, footer, forgetAll, announce*. refresh() method. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-invite-in-accent | var(--mv-accent) | Hover and focus color of the load control, glow of the neutral preview. |
--mv-invite-in-scrim | oklch(0.14 0.01 260 / 0.62) | Darkening over a poster behind the text. |
--mv-invite-in-on-poster | oklch(0.99 0 0) | Text and icon color over a poster. |
--mv-invite-in-radius | var(--mv-radius-lg) | Corner radius of the stage. |
--mv-invite-in-min-height | 16rem | Placeholder height with ratio="auto". |
Accessibility
The placeholder is one real <button> with a complete name (“Load video: Spring, from YouTube”, the visible “Load video” text first so voice control works) and aria-describedby pointing at the privacy note, so the consequence is read before the choice; the provider chip and decorative preview are aria-hidden. Tab then reaches the privacy-policy and open-on-provider links (“opens in a new tab” announced) and the “Always allow” button, which shares the same description. Enter or Space loads; while loading, the element is aria-busy and the buttons aria-disabled. When a click loads the embed, focus moves into the new iframe (or the first focusable element of template content) instead of being lost with the removed button, and the iframe gets a meaningful title (“Spring (YouTube video)”). Granting “Always allow” is announced politely, as is a disconnection that happens while focus is inside the embed (focus then returns to the placeholder button). <mv-invite-in-settings> is a labelled list of native checkbox switches (role="switch", “Always allow YouTube”) whose status line (“Always allowed since Sep 24, 2026 · 1 on this page”) is linked with aria-describedby; changes are announced in a polite status region, and “Forget all” hands focus back to the list when it disappears. Reduced motion (OS or data-motion="reduce"): no poster zoom, no icon bounce, the loading ring becomes static and the reveal is instant. Forced colors: posters and patterns are hidden so text stays on Canvas, the icon uses ButtonFace/ButtonText and focus a Highlight outline.