Exclusivebeta

Invite In — <mv-invite-in>

Privacy-first embed that makes zero requests to a third party until the user invites it in: YouTube and Vimeo videos, maps, social posts, chat widgets, analytics-heavy iframes, third-party font stylesheets. Before consent the embed only exists as an attribute or an inert <template>, never as a live iframe or script; the placeholder keeps the final aspect ratio, shows the app's own poster (or a neutral preview with provider, title and details), says in one line what loading shares (“Loading this video shares your IP address and cookies with YouTube.”) with the provider's privacy policy and an “Open on YouTube” escape hatch, and offers “Load video” (this one, with autoplay) or “Always allow YouTube”. Remembered consent is per provider (per host for plain iframes), stored only when the user opts in (localStorage, session or this page), synced across tabs, and revocable from MvInviteIn.providers() or the companion <mv-invite-in-settings> list: allowed providers load lazily as they near the viewport, revoked ones are disconnected on the spot. Presets rewrite YouTube to youtube-nocookie.com and Vimeo with dnt=1, parse watch, short, embed and playlist URLs, build Google Maps embeds from a plain address, and custom providers register in one call; mv-invite is cancelable for consent logging or your own CMP, and template scripts are re-created (nonce kept) so widgets run only once invited.

CategoryMedia
TypeWeb Component (<mv-invite-in>)
Statusbeta
Also installsbutton, switch, checkbox
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs invite-in --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["invite-in"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): 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.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

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

Cultural reference

Dracula — Bram Stoker (1897, book). In the vampire lore the novel popularized, the vampire cannot cross a threshold until someone inside invites him in, and once invited he may return at will. In the UI, a third-party embed waits outside the page making no request until the user invites it, and “Always allow” lets that provider come back on every page until the invitation is withdrawn.

API

Attributes

NameTypeDefaultDescription
provideryoutube | vimeo | google-maps (alias maps) | iframe | custom idProvider 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.
srcstringWhat 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.
labelstringTitle 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.
subtitlestringSecondary line under the title: duration, author, license, address…
posterURLPreview 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.
kindstringprovider presetNoun used in the texts and icon: video, audio, map, post, chat, content (“Load map”, “Loading this map shares…”).
rememberlocal | session | memory | offlocalWhere “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"trueAdds autoplay when the user clicks to load (the click already expressed intent). Never applied to embeds loaded from a remembered choice.
lazy"true" | "false"trueEmbeds of an allowed provider load when they come within 240px of the viewport instead of all at once.
paramsquery stringExtra parameters set on the final embed URL ("cc_load_policy=1&hl=en", "z=13").
notestringOverrides the one-line privacy note ({name} and {kind} are replaced).
policyURLprovider presetPrivacy policy linked under the note (YouTube and Google Maps: policies.google.com, Vimeo: vimeo.com/privacy). Hidden when none.
allow / sandboxstringOverride the iframe's allow list and add a sandbox.
credentiallessbooleanLoads the iframe as credentialless where supported (Chromium): no cookies or storage shared with the provider's other sites.
data-statewaiting | loading | loadedSet by the component (styleable), with data-provider, data-kind, data-poster and data-ratio="auto". aria-busy while loading.

Properties

NameTypeDefaultDescription
state"waiting" | "loading" | "loaded"Read-only.
urlstring | nullThe embed URL loading will request, before the autoplay flag a click adds (null for <template> content or an invalid src). Read-only.
consentKey / providerName / kindNounstringResolved consent key (youtube, iframe:host…), display name and kind. Read-only.
allowedbooleanWhether this embed's provider is currently remembered as allowed. Read-only.
stringsPartial<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.storageKeystring"mv-invite-in"Static: storage key used for remembered choices.

Methods

NameDescription
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

NameDescription
mv-inviteCancelable, 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-revokeConsent 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-loadThe embed finished loading (or 8 s passed). detail: { provider, name, url }.
mv-unloadThe embed was disconnected (unload() or revocation). detail: { provider, name }.

Content structure

NameDescription
posterAn <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

NameDescription
mv-invite-in-stageThe 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-loadThe placeholder's real <button>: -chip (provider · Not connected), -center, -icon, -action, -title, -subtitle.
mv-invite-in-footPrivacy 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

NameDefaultDescription
--mv-invite-in-accentvar(--mv-accent)Hover and focus color of the load control, glow of the neutral preview.
--mv-invite-in-scrimoklch(0.14 0.01 260 / 0.62)Darkening over a poster behind the text.
--mv-invite-in-on-posteroklch(0.99 0 0)Text and icon color over a poster.
--mv-invite-in-radiusvar(--mv-radius-lg)Corner radius of the stage.
--mv-invite-in-min-height16remPlaceholder 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.