独家
Grin <mv-grin>
为公告横幅、聊天小部件、上传面板、视频播放器和筛选面板提供“关闭后留痕”:被关闭、最小化、闲置或滚动越过的面板不会直接消失,而是逐渐淡去,留下一个微小的常驻残留,实时保留其唯一的关键数据(“New · 2”、未读数、进度环、“4 filters”)。
文化出处
《爱丽丝漫游奇境》,刘易斯·卡罗尔(1865 年,小说)。一只猫非常缓慢地消失,从尾巴尖开始,到它的笑容结束,身体其余部分都不见之后,那个笑容还停留了一会儿。在界面中,被关闭的面板逐部分淡出,最不重要的部分最先消失,而它唯一的关键数据会作为一个小小的残影留下,点击即可让整个面板重新出现。
工作原理
为公告横幅、聊天挂件、上传面板、视频播放器和筛选面板提供“关闭后留下残影”:被关闭、最小化、闲置或已滚动经过的面板不会直接消失,而是逐步隐去,留下一个持续存在的小残影,实时保留它唯一的关键数据(“New · 2”、未读数、进度环、“4 filters”)。各部分用 data-grin="0..3" 声明自己的重要程度:它们按级别依次淡出并模糊,最不重要的先走,离关键部分最远的先走;接着是面板本身;级别最高的部分会单独停留片刻,然后在空间收拢的同时变形为残影。残影是一个真正的按钮(另可附带关闭按钮),它可以内联留在面板原来的位置,也可以停靠到视口或某个容器的角落;多个停靠的残影会在同一个角落堆叠,每次变化时其内容都会重新镜像关键部分(或带 data-grin-mirror 的模板),还可选择附带提示动画和礼貌的读屏播报。点击它会从残影的形状中重新打开面板(裁剪区域逐渐扩大),关键部分最先出现。触发方式:[data-grin-dismiss] 按钮、minimize()、minimized 属性、滚动经过(面板保留其占位,滚动回来时重新出现)以及闲置超时;状态可以按 key 记录在 localStorage 或 sessionStorage 中;mv-vanish、mv-restore 和 mv-close 均可取消。
| 分类 | 过渡效果 |
|---|---|
| 类型 | Web Component(<mv-grin>) |
| 状态 | 稳定版 |
| 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
- A card should expand into a detail view rather than collapse into a residue → use Expandable Card instead
安装
node scripts/add.mjs grin --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["grin"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):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。
用法
标准标记,可在此基础上通过属性、data-* 和 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>Acme 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>API
Attributes
| Name | 类型 | 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 | 类型 | 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.