Exclusivobeta

Grin <mv-grin>

“Descartar dejando un residuo” para banners de anuncios, widgets de chat, paneles de subida, reproductores de video y paneles de filtros: en lugar de desaparecer, un panel descartado, minimizado, inactivo o que ha quedado atrás al desplazarse se desvanece poco a poco y deja un pequeño residuo persistente que conserva su único dato esencial, en vivo (“New · 2”, el número de no leídos, un anillo de progreso, “4 filters”). Las partes declaran lo esenciales que son con data-grin="0..3": se desvanecen y desenfocan rango por rango, primero las menos esenciales y, entre ellas, las más alejadas de la parte esencial; después se va el propio panel, la parte de rango más alto se queda sola un momento y luego se transforma en el residuo mientras el espacio se cierra. El residuo es un botón real (con un botón de cierre opcional) que se queda en línea en el lugar del panel o se acopla a una esquina del viewport o de un contenedor; varios residuos acoplados se apilan en una esquina, y su contenido vuelve a reflejar la parte esencial (o una plantilla con data-grin-mirror) con cada cambio, opcionalmente con un aviso sonoro y un anuncio cortés. Al hacer clic, el panel vuelve a abrirse desde la forma del residuo (recorte que crece), empezando por la parte esencial. Activadores: botones [data-grin-dismiss], minimize(), el atributo minimized, el desplazamiento (el panel conserva su espacio y vuelve cuando se desplaza hasta él) y un tiempo de inactividad; el estado puede recordarse por clave en localStorage o sessionStorage; mv-vanish, mv-restore y mv-close son cancelables.

CategoríaTransiciones
TipoWeb Component (<mv-grin>)
Estadobeta
Keywordsexclusive, culture, dismiss, minimize, collapse, residue, banner, announcement, promo, chat-widget, upload, mini-player, picture-in-picture, filters, scroll, idle, dock, flip, morph, persist, live-region

When to use

  • An announcement or promo banner should step aside without being lost, keeping a small badge the user can reopen later
  • A chat, upload or download panel must get out of the way while still showing its unread count or progress
  • A video or live panel should leave a mini progress indicator in a corner when the user scrolls past it
  • A long filter or settings panel should fold into a one-line summary such as “4 filters” that reopens it in place

Avoid when

  • The message is transient feedback that should disappear on its own, with no way back → use Toast instead
  • The panel must be dismissed for good, with no trace left on the page → use Alert instead
  • A card should expand into a detail view rather than collapse into a residue → use Expandable Card instead

Instalación

node scripts/add.mjs grin --out ./src/marvelous

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["grin"], 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/focus.js, core/motion.js, core/observe.js, components/grin/grin.js, components/grin/grin.css.

Uso

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

<div id="gr-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #gr-demo { display:grid; grid-template-columns:minmax(0,1fr); gap:1rem; align-content:start }
    #gr-demo > * { min-width:0 }
    #gr-demo .gr-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #gr-demo .gr-bar { display:flex; align-items:center; gap:1rem; height:3.25rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
    #gr-demo .gr-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
    #gr-demo .gr-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) }
    #gr-demo .gr-logo svg { width:1rem; height:1rem }
    #gr-demo .gr-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
    #gr-demo .gr-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 }
    #gr-demo .gr-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #gr-demo .gr-me { display:grid; place-items:center; width:2rem; height:2rem; margin-inline-start:auto; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.75rem; font-weight:600 }
    #gr-demo .gr-main { position:relative; display:grid; gap:1rem; align-content:start; height:35rem; padding:1.125rem; overflow:hidden; background:var(--mv-bg-subtle) }

    /* Announcement banner */
    #gr-demo .gr-banner { display:flex; align-items:center; gap:1rem; padding:.875rem 1rem; border:1px solid color-mix(in oklab, var(--mv-accent) 28%, var(--mv-border)); border-radius:var(--mv-radius-lg); background:color-mix(in oklab, var(--mv-accent) 7%, var(--mv-surface)) }
    #gr-demo .gr-art { display:grid; place-items:center; flex:none; width:2.75rem; height:2.75rem; border-radius:var(--mv-radius-lg); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #gr-demo .gr-art svg { width:1.375rem; height:1.375rem }
    #gr-demo .gr-banner-text { display:grid; gap:.125rem; flex:1; min-width:0 }
    #gr-demo .gr-kicker { display:flex; align-items:center; gap:.5rem; margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
    #gr-demo .gr-banner h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #gr-demo .gr-banner p.gr-desc { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
    #gr-demo .gr-banner-actions { display:flex; align-items:center; gap:.375rem; flex:none }

    /* Page content */
    #gr-demo .gr-head h2 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #gr-demo .gr-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #gr-demo .gr-kpis { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.75rem }
    #gr-demo .gr-kpi { display:grid; gap:.25rem; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
    #gr-demo .gr-kpi span { color:var(--mv-fg-muted); font-size:.75rem }
    #gr-demo .gr-kpi b { font-size:1.375rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
    #gr-demo .gr-kpi small { color:var(--mv-success); font-size:.75rem; font-weight:500 }
    #gr-demo .gr-runs { display:grid; margin:0; padding:0; list-style:none; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
    #gr-demo .gr-runs li { display:grid; grid-template-columns:minmax(0,1fr) auto 5.5rem; align-items:center; gap:.75rem; padding:.625rem 1rem; font-size:.8125rem }
    #gr-demo .gr-runs li + li { border-top:1px solid var(--mv-border) }
    #gr-demo .gr-runs li > span:last-child { color:var(--mv-fg-muted); text-align:end; font-variant-numeric:tabular-nums }

    /* Floating widgets */
    #gr-demo .gr-float { position:absolute; bottom:1rem; z-index:2; width:17.5rem }
    #gr-demo .gr-float[data-side="end"] { inset-inline-end:1rem }
    #gr-demo .gr-float[data-side="start"] { inset-inline-start:1rem }
    #gr-demo .gr-panel { display:grid; grid-template-columns:minmax(0,1fr); border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-lg); overflow:hidden }
    #gr-demo .gr-panel-head { display:flex; align-items:center; gap:.625rem; padding:.625rem .625rem .625rem .875rem; border-bottom:1px solid var(--mv-border) }
    #gr-demo .gr-panel-title { display:grid; flex:1; min-width:0; line-height:1.25 }
    #gr-demo .gr-panel-title strong { font-size:.8125rem }
    #gr-demo .gr-panel-title span { overflow:hidden; color:var(--mv-fg-muted); font-size:.6875rem; text-overflow:ellipsis; white-space:nowrap }
    #gr-demo .gr-avatar { display:grid; place-items:center; flex:none; width:1.5rem; height:1.5rem; border-radius:50%; background:color-mix(in oklab, var(--mv-accent) 18%, var(--mv-surface)); color:var(--mv-accent); font-size:.625rem; font-weight:700 }
    #gr-demo .gr-chip { display:inline-flex; align-items:center; gap:.375rem; font-size:.75rem; font-weight:600; white-space:nowrap; font-variant-numeric:tabular-nums }
    #gr-demo .gr-chip .gr-count { display:grid; place-items:center; min-width:1.125rem; height:1.125rem; padding:0 .3125rem; border-radius:var(--mv-radius-full); background:var(--mv-accent); color:var(--mv-fg-on-accent); font-size:.6875rem }
    #gr-demo .gr-log { display:grid; gap:.5rem; margin:0; padding:.75rem .875rem; list-style:none; font-size:.75rem; line-height:1.45 }
    #gr-demo .gr-log li { max-width:88%; padding:.4375rem .625rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted) }
    #gr-demo .gr-log li[data-me] { justify-self:end; background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #gr-demo .gr-compose { display:flex; gap:.375rem; padding:.5rem .625rem .625rem; border-top:1px solid var(--mv-border) }
    #gr-demo .gr-compose .mv-input { flex:1; min-width:0 }
    #gr-demo .gr-ring { --p:0; flex:none; width:1.125rem; height:1.125rem; border-radius:50%; background:conic-gradient(var(--mv-accent) calc(var(--p) * 360deg), var(--mv-bg-emphasis) 0); -webkit-mask:radial-gradient(closest-side, transparent 58%, currentColor 60%); mask:radial-gradient(closest-side, transparent 58%, currentColor 60%) }
    #gr-demo .gr-files { display:grid; gap:.5rem; margin:0; padding:.75rem .875rem .875rem; list-style:none; font-size:.75rem }
    #gr-demo .gr-files li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.25rem .5rem }
    #gr-demo .gr-files li > span:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500 }
    #gr-demo .gr-files li > span:nth-child(2) { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
    #gr-demo .gr-files i { grid-column:1 / -1; height:4px; border-radius:2px; background:linear-gradient(90deg, var(--mv-accent) calc(var(--v, 0) * 100%), var(--mv-bg-muted) 0) }

    /* Controls */
    #gr-demo .gr-controls { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #gr-demo .gr-controls .gr-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #gr-demo .gr-status { flex:1 1 12rem; min-width:0; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #gr-demo .mv-choice { font-size:.8125rem }

    /* Lower row */
    #gr-demo .gr-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1rem }
    #gr-demo .gr-card { display:grid; gap:.75rem; align-content:start; min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #gr-demo .gr-cap { display:flex; align-items:baseline; justify-content:space-between; gap:.5rem; margin:0; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
    #gr-demo .gr-cap code { font:500 .6875rem var(--mv-font-mono); letter-spacing:0; text-transform:none; color:var(--mv-fg-subtle) }
    #gr-demo .gr-filters { display:grid; gap:.625rem; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #gr-demo .gr-filters-head { display:flex; align-items:center; gap:.5rem }
    #gr-demo .gr-filters-head strong { font-size:.8125rem }
    #gr-demo .gr-filters-head .mv-button { margin-inline-start:auto }
    #gr-demo .gr-filters fieldset { display:flex; flex-wrap:wrap; align-items:center; gap:.25rem .875rem; margin:0; padding:0; border:0; min-width:0 }
    #gr-demo .gr-filters legend { float:left; width:4rem; padding:0; color:var(--mv-fg-muted); font-size:.75rem }
    #gr-demo .gr-results { display:grid; gap:.375rem; margin:0; padding:0; list-style:none }
    #gr-demo .gr-results li { display:flex; justify-content:space-between; gap:.75rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); font-size:.8125rem }
    #gr-demo .gr-results li span:last-child { color:var(--mv-fg-muted) }
    #gr-demo .gr-funnel { width:.875rem; height:.875rem; color:var(--mv-fg-muted) }
    #gr-demo .gr-reader { height:23rem; overflow:auto; overscroll-behavior:contain; padding-bottom:3.5rem }
    #gr-demo .gr-reader:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
    #gr-demo .gr-reader h4 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #gr-demo .gr-reader p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.6 }
    #gr-demo .gr-video { display:grid; gap:0; margin:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); overflow:hidden; background:var(--mv-surface) }
    #gr-demo .gr-frame { position:relative; display:grid; place-items:center; aspect-ratio:16 / 7; background:radial-gradient(120% 90% at 20% 10%, color-mix(in oklab, var(--mv-accent) 38%, var(--mv-bg-emphasis)), var(--mv-bg-emphasis) 70%) }
    #gr-demo .gr-frame-play { display:grid; place-items:center; width:3rem; height:3rem; border-radius:50%; background:color-mix(in oklab, var(--mv-surface) 82%, transparent); color:var(--mv-fg); box-shadow:var(--mv-shadow-md) }
    #gr-demo .gr-frame-play svg { width:1.25rem; height:1.25rem; margin-inline-start:2px }
    #gr-demo .gr-frame-caption { position:absolute; left:.75rem; bottom:.625rem; margin:0; color:var(--mv-fg); font-size:.75rem; font-weight:600 }
    #gr-demo .gr-video-bar { display:flex; align-items:center; gap:.5rem; padding:.375rem .5rem .375rem .375rem }
    #gr-demo .gr-video-bar .gr-chip { font-weight:500 }
    #gr-demo .gr-track { flex:1; height:4px; border-radius:2px; background:linear-gradient(90deg, var(--mv-accent) calc(var(--p, 0) * 100%), var(--mv-bg-muted) 0) }
    @media (max-width:46rem) {
      #gr-demo .gr-nav, #gr-demo .gr-desc, #gr-demo .gr-art, #gr-demo .gr-banner-actions .mv-button[href] { display:none }
      #gr-demo .gr-banner { align-items:flex-start; padding:.75rem .5rem .75rem .875rem }
      #gr-demo .gr-kicker { flex-wrap:wrap }
      #gr-demo .gr-kpis { grid-template-columns:repeat(2,minmax(0,1fr)) }
      #gr-demo .gr-kpi:last-child { display:none }
      #gr-demo .gr-row { grid-template-columns:minmax(0,1fr) }
      #gr-demo .gr-float { width:min(17.5rem, calc(100% - 2rem)) }
      #gr-demo .gr-float[data-side="start"] { width:calc(100% - 9.5rem) }
    }
  </style>

  <!-- A workflow dashboard: an announcement docks to the top corner, the chat and the upload panel to the bottom corners -->
  <section class="gr-app" aria-label="Workflow dashboard">
    <header class="gr-bar">
      <span class="gr-brand">
        <span class="gr-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v3A4.5 4.5 0 0 0 10.5 16h5"/></svg></span>
        Relay
      </span>
      <ul class="gr-nav">
        <li><a href="#runs" aria-current="page">Runs</a></li>
        <li><a href="#workflows">Workflows</a></li>
        <li><a href="#settings">Settings</a></li>
      </ul>
      <span class="gr-me" aria-hidden="true">SK</span>
    </header>

    <div class="gr-main">
      <mv-grin id="gr-banner" label="Launch week banner" dock="top-end" dock-within=".gr-main" closable>
        <aside class="gr-banner" aria-label="Launch week">
          <span class="gr-art" data-grin="0" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="9" r="2"/><path d="M6 7v10M18 11c0 4-6 3-11.2 6.6"/></svg></span>
          <div class="gr-banner-text">
            <p class="gr-kicker"><span class="mv-badge" data-variant="info" data-shape="pill" data-grin="3" data-grin-label="2 new features">New · 2</span><span data-grin="1">Launch week · Day 3 of 5</span></p>
            <h3 data-grin="2">Branching workflows are live</h3>
            <p class="gr-desc" data-grin="0">Route any run down parallel paths and merge the results. Available on every plan today.</p>
          </div>
          <span class="gr-banner-actions">
            <a class="mv-button" data-size="sm" href="#whats-new" data-grin="1">See what’s new</a>
            <button type="button" class="mv-button" data-variant="ghost" data-size="icon" data-grin-dismiss aria-label="Dismiss launch week banner"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M17 7 7 17M7 7l10 10"/></svg></button>
          </span>
        </aside>
      </mv-grin>

      <div class="gr-head">
        <h2>Runs</h2>
        <p>Northwind Labs · last 24 hours</p>
      </div>
      <div class="gr-kpis">
        <div class="gr-kpi"><span>Runs today</span><b>1,284</b><small>+12% vs. yesterday</small></div>
        <div class="gr-kpi"><span>Success rate</span><b>98.6%</b><small>+0.4 pts</small></div>
        <div class="gr-kpi"><span>Median duration</span><b>42 s</b><small>−6 s</small></div>
      </div>
      <ul class="gr-runs">
        <li><span>Nightly invoice sync</span><span class="mv-badge" data-variant="success" data-shape="pill">Succeeded</span><span>2 min ago</span></li>
        <li><span>Onboarding emails · EU</span><span class="mv-badge" data-variant="info" data-shape="pill">Running</span><span>6 min ago</span></li>
        <li><span>CRM enrichment</span><span class="mv-badge" data-variant="success" data-shape="pill">Succeeded</span><span>14 min ago</span></li>
        <li><span>Warehouse export</span><span class="mv-badge" data-variant="danger" data-shape="pill">Failed</span><span>31 min ago</span></li>
      </ul>

      <!-- Upload panel: minimizes itself after 6 s without interaction -->
      <mv-grin id="gr-upload" class="gr-float" data-side="start" label="Uploads" dock="bottom-start" dock-within=".gr-main" idle="6s">
        <section class="gr-panel" aria-label="Uploads">
          <header class="gr-panel-head">
            <span class="gr-chip" data-grin="3" data-grin-label="Uploading, 38%"><span class="gr-ring" style="--p:.38"></span><span class="gr-pct">38%</span></span>
            <span class="gr-panel-title"><strong data-grin="2">Uploading 3 files</strong><span data-grin="1" class="gr-eta">About 40 s left</span></span>
            <button type="button" class="mv-button" data-variant="ghost" data-size="icon" data-grin-dismiss aria-label="Minimize uploads"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 12h12"/></svg></button>
          </header>
          <ul class="gr-files" data-grin="0">
            <li><span>q3-board-report.pdf</span><span>2.4 MB</span><i style="--v:1"></i></li>
            <li><span>product-shots-fall.zip</span><span class="gr-file-pct">48 MB</span><i class="gr-file-bar" style="--v:.2"></i></li>
            <li><span>vendor-contracts.xlsx</span><span>Queued</span><i style="--v:0"></i></li>
          </ul>
        </section>
      </mv-grin>

      <!-- Support chat: starts minimized, its residue keeps the unread count live -->
      <mv-grin id="gr-chat" class="gr-float" data-side="end" label="Support chat" dock="bottom-end" dock-within=".gr-main" live minimized>
        <section class="gr-panel" aria-label="Support chat">
          <header class="gr-panel-head">
            <span class="gr-chip" data-grin="3" data-grin-label="3 unread messages"><span class="gr-avatar">AO</span><span class="gr-count">3</span><span class="gr-unread">unread</span></span>
            <span class="gr-panel-title" data-grin="2"><strong>Amara Okafor</strong><span>Support · online</span></span>
            <button type="button" class="mv-button" data-variant="ghost" data-size="icon" data-grin-dismiss aria-label="Minimize chat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 12h12"/></svg></button>
          </header>
          <ol class="gr-log" data-grin="0" id="gr-chat-log">
            <li>The warehouse export failed because the bucket rotated its keys at 09:12.</li>
            <li>Want me to re-run it once you’ve updated them?</li>
          </ol>
          <form class="gr-compose" data-grin="1" id="gr-compose">
            <input class="mv-input" data-size="sm" placeholder="Write a reply…" aria-label="Reply to Amara">
            <button class="mv-button" data-size="sm" type="submit">Send</button>
          </form>
        </section>
      </mv-grin>
    </div>
  </section>

  <div class="gr-controls">
    <span class="gr-actions">
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="gr-min">Minimize all</button>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="gr-max">Restore all</button>
      <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="gr-msg">Simulate a new message</button>
    </span>
    <p class="gr-status" id="gr-status" aria-hidden="true">Dismiss the banner, or wait 6 s for the upload panel.</p>
    <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="gr-slow"> Slow motion</label>
  </div>

  <div class="gr-row">
    <!-- Inline: the residue takes the panel's place, from a template that mirrors the count -->
    <div class="gr-card">
      <p class="gr-cap">Inline residue <code>template + data-grin-mirror</code></p>
      <mv-grin id="gr-filters" label="Filters">
        <form class="gr-filters" aria-label="Filters">
          <div class="gr-filters-head">
            <strong data-grin="2">Filters</strong>
            <span class="mv-badge" data-variant="secondary" data-shape="pill" data-grin="3"><span class="gr-fcount">4 filters</span></span>
            <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-grin-dismiss>Hide</button>
          </div>
          <fieldset data-grin="1">
            <legend>Status</legend>
            <label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Active</label>
            <label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Trialing</label>
            <label class="mv-choice"><input type="checkbox" class="mv-checkbox"> Churned</label>
          </fieldset>
          <fieldset data-grin="0">
            <legend>Region</legend>
            <label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Europe</label>
            <label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Americas</label>
            <label class="mv-choice"><input type="checkbox" class="mv-checkbox"> Asia-Pacific</label>
          </fieldset>
        </form>
        <template slot="residue"><svg class="gr-funnel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.5V19l-4 1.5v-8z"/></svg><span data-grin-mirror=".gr-fcount"></span></template>
      </mv-grin>
      <ul class="gr-results">
        <li><span>Globex Corporation</span><span>Active · $4,200 / mo</span></li>
        <li><span>Initech GmbH</span><span>Trialing · 9 days left</span></li>
        <li><span>Umbrella Health</span><span>Active · $1,150 / mo</span></li>
        <li><span>Tanaka Logistics</span><span>Active · $2,780 / mo</span></li>
      </ul>
    </div>

    <!-- Scroll past: the video leaves a mini progress residue in its pane, and comes back when scrolled to -->
    <div class="gr-card gr-reader" tabindex="0" role="region" aria-label="Article: How we cut deploy times by 40%">
      <p class="gr-cap">Scroll past <code>scroll-past</code></p>
      <h4>How we cut deploy times by 40%</h4>
      <p>Our median deploy used to take four and a half minutes. Most of it was spent waiting on caches that were never warm when we needed them.</p>
      <mv-grin id="gr-video" label="Video: Deploys in 90 seconds" scroll-past dock="bottom-end" dock-within=".gr-reader">
        <figure class="gr-video">
          <div class="gr-frame" data-grin="0"><span class="gr-frame-play" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 6.8v10.4a.8.8 0 0 0 1.2.7l8.2-5.2a.8.8 0 0 0 0-1.4l-8.2-5.2a.8.8 0 0 0-1.2.7z"/></svg></span><p class="gr-frame-caption">Deploys in 90 seconds</p></div>
          <div class="gr-video-bar">
            <button type="button" class="mv-button" data-variant="ghost" data-size="icon" id="gr-play" aria-label="Pause" data-grin="1"><svg class="gr-play-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" hidden><path d="M8 5.5v13a1 1 0 0 0 1.5.9l10-6.5a1 1 0 0 0 0-1.7l-10-6.5A1 1 0 0 0 8 5.5z"/></svg><svg class="gr-pause-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6.5" y="5" width="3.5" height="14" rx="1"/><rect x="14" y="5" width="3.5" height="14" rx="1"/></svg></button>
            <span class="gr-track" data-grin="0" id="gr-track" style="--p:.42"></span>
            <span class="gr-chip" data-grin="3" id="gr-time" data-grin-label="Playing, 1:42 of 4:05"><span class="gr-ring" style="--p:.42"></span><span class="gr-t">1:42 / 4:05</span></span>
          </div>
        </figure>
      </mv-grin>
      <p>We split the pipeline into branches that build the API, the web app and the workers in parallel, then merge the artifacts in a single promotion step.</p>
      <p>Warm caches now live next to each runner. A cold start that took 70 seconds takes 9, and the long tail of flaky network fetches disappeared.</p>
      <p>The last minute went to smoke tests that ran one after another. They now run as a matrix, and a failure in one shard cancels the others immediately.</p>
      <p>The result: a median deploy of 2 minutes 41 seconds, and a p95 under four minutes, measured over 3,400 production deploys in August.</p>
      <p>Next quarter we will move preview environments to the same runners, so that every pull request gets a live URL in under a minute.</p>
      <p>If you want to try the branching setup on your own pipeline, the template is in the Workflows gallery under “Parallel build and merge”.</p>
    </div>
  </div>

  <script type="module">
    const root = document.getElementById("gr-demo");
    await customElements.whenDefined("mv-grin");
    const $ = (id) => document.getElementById(id);
    const grins = [...root.querySelectorAll("mv-grin")];
    const status = $("gr-status");
    const alive = () => root.isConnected;

    root.addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
    root.addEventListener("mv-vanish", (e) => { status.textContent = `mv-vanish · ${e.detail.label} · ${e.detail.reason}`; });
    root.addEventListener("mv-restore", (e) => { status.textContent = `mv-restore · ${e.detail.label} · ${e.detail.reason}`; });
    root.addEventListener("mv-close", (e) => { status.textContent = `mv-close · ${e.detail.label}`; });

    $("gr-min").addEventListener("click", () => grins.forEach((g) => g.minimize()));
    $("gr-max").addEventListener("click", () => grins.forEach((g) => g.restore()));
    $("gr-slow").addEventListener("change", (e) => {
      const k = e.target.checked ? 3 : 1;
      for (const [name, ms] of [["step", 110], ["fade", 320], ["hold", 420], ["morph", 460]]) {
        root.style.setProperty(`--mv-grin-${name}`, `${ms * k}ms`);
      }
    });

    // Chat: new messages count as unread only while the chat is minimized.
    const chat = $("gr-chat");
    const chatLog = $("gr-chat-log");
    const chip = chat.querySelector(".gr-chip");
    let unread = 3;
    let msgIndex = 0;
    const lines = ["Keys are updated on our side too.", "The re-run finished in 38 seconds.", "Anything else I can help with?", "I’ll close this ticket tomorrow if all is well."];
    const setUnread = (n) => {
      unread = n;
      chip.querySelector(".gr-count").textContent = String(n);
      chip.querySelector(".gr-count").hidden = n === 0;
      chip.querySelector(".gr-unread").textContent = n ? "unread" : "All read";
      chip.setAttribute("data-grin-label", n ? `${n} unread message${n === 1 ? "" : "s"}` : "no unread messages");
    };
    $("gr-msg").addEventListener("click", () => {
      const li = document.createElement("li");
      li.textContent = lines[msgIndex++ % lines.length];
      chatLog.append(li);
      while (chatLog.children.length > 2) chatLog.firstElementChild.remove();
      setUnread(chat.state === "full" ? 0 : unread + 1);
    });
    chat.addEventListener("mv-restore", () => setUnread(0));
    $("gr-compose").addEventListener("submit", (e) => {
      e.preventDefault();
      const input = e.target.querySelector("input");
      if (!input.value.trim()) return;
      const li = document.createElement("li");
      li.dataset.me = "";
      li.textContent = input.value.trim();
      chatLog.append(li);
      while (chatLog.children.length > 2) chatLog.firstElementChild.remove();
      input.value = "";
    });

    // Filters: the count the residue mirrors.
    const filters = $("gr-filters");
    filters.addEventListener("change", () => {
      const n = filters.querySelectorAll("input[type=checkbox]:checked").length;
      filters.querySelector(".gr-fcount").textContent = n ? `${n} filter${n === 1 ? "" : "s"}` : "No filters";
    });

    // Upload: progress keeps moving, minimized or not.
    const up = $("gr-upload");
    let pct = 38;
    const upTick = setInterval(() => {
      if (!alive()) return clearInterval(upTick);
      pct = pct >= 100 ? 8 : Math.min(100, pct + 1);
      const done = pct === 100;
      const upChip = up.querySelector(".gr-chip");
      upChip.querySelector(".gr-ring").style.setProperty("--p", (pct / 100).toFixed(2));
      upChip.querySelector(".gr-pct").textContent = done ? "Done" : `${pct}%`;
      upChip.setAttribute("data-grin-label", done ? "Upload complete" : `Uploading, ${pct}%`);
      up.querySelector("strong").textContent = done ? "3 files uploaded" : "Uploading 3 files";
      up.querySelector(".gr-eta").textContent = done ? "Just now" : `About ${Math.max(1, Math.round((100 - pct) * 0.6))} s left`;
      up.querySelector(".gr-file-bar").style.setProperty("--v", Math.min(1, pct / 80).toFixed(2));
    }, 600);

    // Video: a fake playback clock drives the track and the residue.
    const total = 245;
    let t = 102;
    let playing = true;
    const play = $("gr-play");
    const fmt = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
    const paint = () => {
      const p = (t / total).toFixed(3);
      $("gr-track").style.setProperty("--p", p);
      const time = $("gr-time");
      time.querySelector(".gr-ring").style.setProperty("--p", p);
      time.querySelector(".gr-t").textContent = `${fmt(t)} / ${fmt(total)}`;
      time.setAttribute("data-grin-label", `${playing ? "Playing" : "Paused"}, ${fmt(t)} of ${fmt(total)}`);
    };
    play.addEventListener("click", () => {
      playing = !playing;
      play.querySelector(".gr-play-icon").toggleAttribute("hidden", playing);
      play.querySelector(".gr-pause-icon").toggleAttribute("hidden", !playing);
      play.setAttribute("aria-label", playing ? "Pause" : "Play");
      paint();
    });
    const vidTick = setInterval(() => {
      if (!alive()) return clearInterval(vidTick);
      if (!playing) return;
      t = t >= total ? 0 : t + 1;
      paint();
    }, 1000);
  </script>
</div>

Referencia cultural

Alicia en el país de las maravillas, Lewis Carroll (1865, libro). Un gato desaparece muy despacio, empezando por la punta de la cola y terminando por su sonrisa, que permanece un rato después de que el resto se haya ido. En la interfaz, un panel descartado se desvanece parte por parte, empezando por la menos esencial, y su único dato esencial persiste como un pequeño residuo que devuelve el panel entero al hacer clic.

API

Attributes

NameTipoDefaultDescription
dockinline | top-start | top-end | bottom-start | bottom-endinlineWhere the residue lives. inline: in the panel’s place (the space collapses to the residue). A corner: fixed in that corner (top layer when the Popover API exists) and the panel’s space collapses to nothing. start/end follow the writing direction; residues sharing a corner stack in DOM order.
dock-withinCSS selectorDock in the corner of this container (closest ancestor matching it, else the first match in the document) instead of the viewport. The residue follows the visible part of the container while it scrolls and hides while the container is out of view.
scroll-pastbooleanMinimize when the panel scrolls out past the top of the viewport (or of its scrolling container) and restore it automatically when it comes back into view. The panel keeps its space (no layout shift) and the residue docks to the dock corner, bottom-end when dock is inline. Clicking the residue scrolls back to the panel.
idletime ("6s", "1m", "500ms", ms number)Minimize after this long without pointer, key, wheel or focus activity inside the panel. Never while it is hovered or holds focus; re-armed after every restore.
minimizedbooleanReflects the state (set while vanishing, minimized or closed). Set it in the markup to start as a residue; toggling it minimizes or restores with the animation (reason "attribute").
persiststringStorage key: minimizing (not scrolling past) and closing are remembered as mv-grin:<key>, and the panel starts in that state on the next visit. Restoring clears it.
storagelocal | sessionlocalWhere persist writes: localStorage or sessionStorage. Failures (private mode, quotas) are ignored.
labelstringName of the panel used in the residue button (“Restore: Launch week banner, 2 new”) and in announcements. Defaults to the panel’s aria-label, then its first heading.
closablebooleanAdds a small close button to the residue: it removes panel and residue (cancelable mv-close) until restore() is called, and is remembered with persist.
livebooleanWhen the residue’s datum changes while minimized, the residue pings once and the new value is announced politely (“Support chat: 4 unread messages”). Leave it off for data that changes continuously, such as progress: the residue still updates, silently.
data-grinnumber (0..3, any integer)On any element of the panel: how essential it is. Lower ranks vanish first (unranked leaves count as 0); the highest-ranked element is the essential part that lingers and becomes the residue. A part vanishes with its container, so do not nest a high rank inside a lower-ranked element.
data-grin-labelstringOn the essential part (or inside the residue template): spoken form of its datum used in the residue’s accessible name, e.g. “2 new features” for “New · 2”. Keep it updated with the value. Defaults to the visible text.
data-grin-dismissmarker attributeOn a button or link inside the panel: minimizes it (reason "dismiss").
data-grin-bodymarker attributeMarks the panel element among the children. Default: the first element child that is not a <template>.
data-grin-mirrorCSS selectorInside the residue template: this element’s content is replaced with a copy of the matching element of the panel (and its inline custom properties) on every change.
data-grin-focus / autofocusmarker attributeInside the panel: where focus goes when the panel is restored from its residue by keyboard or click. Default: the first focusable element, else the panel itself.
data-statefull | vanishing | residue | restoring | closedSet by the component on the host (styleable). While away, the panel carries data-grin-away="gone" (display: none) or "kept" (visibility: hidden, after a scroll-past).

Properties

NameTipoDescription
state"full" | "vanishing" | "residue" | "restoring" | "closed"Current state (read-only).
residueHTMLElementThe residue wrapper (.mv-grin-residue), read-only: style it, measure it, or attach a tooltip.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement: restore ({label}), restoreWith ({label}, {datum}), close, fallbackLabel, announceMinimized, announceRestored, announceChanged, announceClosed. English defaults.
dock / dockWithin / scrollPast / idle / persist / storage / label / minimized / closable / livereflectedMirror the attributes.

Methods

NameDescription
minimize({ reason? })Vanishes to the residue (reason defaults to "api"). Emits the cancelable mv-vanish; returns Promise<boolean>, true once the residue is in place, false if cancelled, interrupted or already minimized.
restore({ reason? })Brings the panel back from its residue (or after close()). Emits the cancelable mv-restore; returns Promise<boolean>.
toggle()minimize() when the panel is shown, restore() otherwise.
close()Removes the panel and its residue until restore(). Emits the cancelable mv-close; returns boolean.

Events

NameDescription
mv-vanishCancelable, before the panel vanishes. detail: { reason: "dismiss" | "api" | "attribute" | "idle" | "scroll", label }. preventDefault() keeps the panel (e.g. to ask for confirmation first).
mv-restoreCancelable, before the panel comes back. detail: { reason: "residue" | "api" | "attribute" | "scroll", label }. preventDefault() keeps the residue (e.g. to open a full page instead).
mv-closeCancelable, before the close button of the residue (or close()) removes everything. detail: { label }.

Content structure

NameDescription
(panel)The first element child (or [data-grin-body]) is the panel that vanishes; mark its parts with data-grin.
residue<template slot="residue">: custom residue content instead of a copy of the essential part, e.g. an icon and a <span data-grin-mirror=".count">. The essential part still lingers during the vanish.

CSS classes

NameDescription
mv-grin-residueResidue wrapper: data-dock="inline | <corner>", data-offscreen while its container is out of view, data-ping="a | b" for one pulse after a change (with live).
mv-grin-residue-button / -content / -iconThe restore button, the mirrored datum (aria-hidden, inert) and the small expand glyph that brightens on hover and focus.
mv-grin-residue-closeOptional close button (closable).
mv-grin-layer / mv-grin-ghostFixed, aria-hidden layer that holds the frozen clone of the essential part while it lingers and flies, and the residue’s echo while the panel reopens.

CSS variables

NameDefaultDescription
--mv-grin-step110msDelay between two ranks while vanishing (reappearing uses about half).
--mv-grin-fade320msHow long each part takes to fade and blur out (or back in).
--mv-grin-hold420msHow long the essential part lingers alone before it becomes the residue.
--mv-grin-morph460msDuration of the flight into the residue, of the space closing, and of the panel reopening.
--mv-grin-inset16pxDistance between a docked residue and its corner.
--mv-grin-gap8pxGap between residues stacked in the same corner.
--mv-grin-bgvar(--mv-surface-raised)Residue background.
--mv-grin-radiusvar(--mv-radius-full)Residue corner radius.
--mv-grin-pingvar(--mv-accent)Color of the pulse when the residue’s datum changes.

Accessibility

The residue is a real <button> in the panel’s own place in the DOM, so it keeps its natural position in the tab order even when it is drawn in a corner (a manual popover in the top layer never reorders focus). Its accessible name combines the panel label and the spoken datum, “Restore: Launch week banner, 2 new features”, and is updated whenever the datum changes; the visual copy inside is aria-hidden and inert, so nothing in it is focusable or read twice. Focus is never lost: if it was inside the panel when it vanishes (keyboard or mouse dismissal, idle, API), it moves to the residue right before the panel is hidden, and when the panel is restored from a focused residue it moves to [autofocus] / [data-grin-focus], else the first focusable element, else the panel itself (tabindex="-1"). The optional close button is named “Close <label>” and hands focus to the next focusable element on the page. A polite live region announces “<label> minimized.” and “<label> restored.” (not for scroll-past, which would chatter while scrolling), and with live the residue’s new values. While away, the panel is display: none (or visibility: hidden after a scroll-past), so assistive technologies and Tab skip it; the idle timer never fires while the panel is hovered or holds focus. Reduced motion (OS or data-motion="reduce"): a plain 140 ms crossfade between panel and residue, no stagger, blur, flight, clip or ping. Forced colors: the residue uses system colors and a Highlight focus outline.

Esta página se tradujo con IA. Informar de un problema de traducción