Exclusivebeta
Grin — <mv-grin>
“Dismiss to residue” for announcement banners, chat widgets, upload panels, video players and filter panels: instead of disappearing, a dismissed, minimized, idle or scrolled-past panel vanishes progressively and leaves a tiny persistent residue that keeps its single essential datum, live (“New · 2”, the unread count, a progress ring, “4 filters”). Parts declare how essential they are with data-grin="0..3": they fade and blur out rank by rank, least essential first and farthest from the essential part first, the panel itself goes next, the highest-ranked part lingers alone for a moment, then morphs into the residue while the space closes. The residue is a real button (plus an optional close button) that stays inline in the panel’s place or docks to a corner of the viewport or of a container, several docked residues stack in one corner, and its content re-mirrors the essential part (or a template with data-grin-mirror) on every change, optionally with a ping and a polite announcement. Clicking it reopens the panel out of the residue’s shape (growing clip), essential part first. Triggers: [data-grin-dismiss] buttons, minimize(), the minimized attribute, scroll-past (the panel keeps its space and comes back when scrolled to) and an idle timeout; state can be remembered per key in localStorage or sessionStorage; mv-vanish, mv-restore and mv-close are cancelable.
| Category | Transitions |
|---|---|
| Type | Web Component (<mv-grin>) |
| Status | beta |
| Keywords | exclusive, 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
Install
node scripts/add.mjs grin --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["grin"], 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/focus.js, core/motion.js, core/observe.js, components/grin/grin.js, components/grin/grin.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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>Cultural reference
Alice’s Adventures in Wonderland — Lewis Carroll (1865, book). A cat vanishes quite slowly, beginning with the end of its tail and ending with its grin, which remains some time after the rest of it has gone. In the UI, a dismissed panel fades away part by part, least essential first, and its single essential datum lingers as a small residue that brings the whole panel back when clicked.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
dock | inline | top-start | top-end | bottom-start | bottom-end | inline | Where 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-within | CSS selector | Dock 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-past | boolean | Minimize 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. | |
idle | time ("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. | |
minimized | boolean | Reflects 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"). | |
persist | string | Storage 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. | |
storage | local | session | local | Where persist writes: localStorage or sessionStorage. Failures (private mode, quotas) are ignored. |
label | string | Name 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. | |
closable | boolean | Adds a small close button to the residue: it removes panel and residue (cancelable mv-close) until restore() is called, and is remembered with persist. | |
live | boolean | When 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-grin | number (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-label | string | On 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-dismiss | marker attribute | On a button or link inside the panel: minimizes it (reason "dismiss"). | |
data-grin-body | marker attribute | Marks the panel element among the children. Default: the first element child that is not a <template>. | |
data-grin-mirror | CSS selector | Inside 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 / autofocus | marker attribute | Inside 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-state | full | vanishing | residue | restoring | closed | Set 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
| Name | Type | Description |
|---|---|---|
state | "full" | "vanishing" | "residue" | "restoring" | "closed" | Current state (read-only). |
residue | HTMLElement | The residue wrapper (.mv-grin-residue), read-only: style it, measure it, or attach a tooltip. |
strings | Partial<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 / live | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-vanish | Cancelable, before the panel vanishes. detail: { reason: "dismiss" | "api" | "attribute" | "idle" | "scroll", label }. preventDefault() keeps the panel (e.g. to ask for confirmation first). |
mv-restore | Cancelable, 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-close | Cancelable, before the close button of the residue (or close()) removes everything. detail: { label }. |
Content structure
| Name | Description |
|---|---|
(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
| Name | Description |
|---|---|
mv-grin-residue | Residue 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 / -icon | The restore button, the mirrored datum (aria-hidden, inert) and the small expand glyph that brightens on hover and focus. |
mv-grin-residue-close | Optional close button (closable). |
mv-grin-layer / mv-grin-ghost | Fixed, 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
| Name | Default | Description |
|---|---|---|
--mv-grin-step | 110ms | Delay between two ranks while vanishing (reappearing uses about half). |
--mv-grin-fade | 320ms | How long each part takes to fade and blur out (or back in). |
--mv-grin-hold | 420ms | How long the essential part lingers alone before it becomes the residue. |
--mv-grin-morph | 460ms | Duration of the flight into the residue, of the space closing, and of the panel reopening. |
--mv-grin-inset | 16px | Distance between a docked residue and its corner. |
--mv-grin-gap | 8px | Gap between residues stacked in the same corner. |
--mv-grin-bg | var(--mv-surface-raised) | Residue background. |
--mv-grin-radius | var(--mv-radius-full) | Residue corner radius. |
--mv-grin-ping | var(--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.