Exclusivobeta
Snowball <mv-snowball>
Seleção por varredura para grades e listas (bibliotecas de fotos, gerenciadores de arquivos, caixas de entrada, catálogos): segure Shift (ou qualquer modificador, ou nenhum) e passe o ponteiro pelos itens. É o trajeto, não um retângulo, que decide o que é escolhido, então você pode serpentear entre fotos espalhadas sem pegar as vizinhas. Cada item tocado gruda em uma bola de seleção que segue o ponteiro. O item dá um pequeno puxão magnético, uma miniatura voa até a bola e rola com ela em 3D enquanto você se move, e a contagem fica dentro dela. A bola cresce com a seleção e alcança um pouco mais longe à medida que cresce. Segurar Alt enquanto arrasta desmarca, e o modo paint deixa o primeiro item decidir. Escape cancela toda a varredura. Ao soltar, a bola cai em uma bandeja fixa com as suas ações em massa (slot="actions", com um evento mv-action), além de Select all e Clear. No toque, é pressionar longamente e depois arrastar, com rolagem automática nas bordas do contêiner de rolagem. O teclado é tratado como prioridade: Shift + setas percorre uma grade ou lista, voltar desmarca, Espaço alterna, Ctrl/Cmd+A seleciona tudo. Os itens mantêm uma semântica real (aria-selected em options ou gridcells, ou a checkbox que contêm, cujos eventos input/change continuam disparando). A detecção de colisão usa retângulos em cache combinados com os deslocamentos de rolagem, e a bola roda em um frameLoop que dorme quando nada se mexe. Prefira um laço retangular para canvas livres selecionados por área, e checkboxes simples para poucos itens.
| Categoria | Exibição de dados |
|---|---|
| Tipo | Web Component (<mv-snowball>) |
| Status | beta |
| Kit | Navegação para usuários avançados |
| Keywords | exclusive, culture, selection, multi-select, sweep-select, bulk-actions, batch, photo-grid, file-manager, gallery, listbox, gesture, touch, long-press, keyboard, lasso-alternative, checkbox, tray, auto-scroll |
When to use
- A photo library or file manager needs to pick dozens of scattered items in one gesture instead of clicking each checkbox
- Items sit in a dense gri d where a rectangle lasso would also catc h the unwanted neighbors between the min
- People must select many items on a phone, where a long-press then drag beats tapping them one by one
- Bulk actions such as delete, move or download should appear in a sticky tray right where the selection happens
Avoid when
- Rows are tabular records that need sorting, filtering and Shift + click ranges → use Data table instead
- There are only a handful of options, where plain checkboxes are clearer → use Checkbox instead
- Items must also be dragged to reorder or move between columns: the two gestures would compete → use Kanban instead
Instalação
node scripts/add.mjs snowball --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["snowball"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/snowball/snowball.js, components/snowball/snowball.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-snowball>
<ul role="listbox" aria-label="Photos" aria-multiselectable="true">
<li role="option" data-value="IMG_4101">Harbor</li><li role="option" data-value="IMG_4102">Beach</li><li role="option" data-value="IMG_4103">Glacier</li>
</ul>
</mv-snowball>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="sb-demo">
<style>
#sb-demo { display:grid; gap:1.25rem; width:min(100%,58rem); margin-inline:auto; align-content:start }
#sb-demo .sb-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#sb-demo .sb-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#sb-demo .sb-title { display:grid; gap:.125rem; min-width:0 }
#sb-demo .sb-title strong { font-size:.9375rem; letter-spacing:-.01em }
#sb-demo .sb-title span { color:var(--mv-fg-muted); font-size:.8125rem }
#sb-demo .sb-hint { display:flex; align-items:center; gap:.375rem; flex-wrap:wrap; margin:0; color:var(--mv-fg-muted); font-size:.75rem }
#sb-demo .sb-hint i { font-style:normal; color:var(--mv-fg-subtle) }
/* Photo grid */
#sb-demo .sb-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(8.25rem,1fr)); gap:.5rem; margin:0; padding:1rem 1.25rem 0; list-style:none }
#sb-demo .sb-grid li { position:relative; aspect-ratio:4/3; overflow:hidden; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); transition:background-color var(--mv-duration-fast) }
#sb-demo .sb-grid img { display:block; width:100%; height:100%; object-fit:cover; transition:scale var(--mv-duration-normal) var(--mv-ease-out), border-radius var(--mv-duration-normal) var(--mv-ease-out); -webkit-user-drag:none }
#sb-demo .sb-grid li[aria-selected="true"] { background:var(--mv-accent-subtle) }
#sb-demo .sb-grid li[aria-selected="true"] img { scale:.84; border-radius:var(--mv-radius-sm) }
#sb-demo .sb-tick { position:absolute; top:.375rem; left:.375rem; width:1.125rem; height:1.125rem; box-sizing:border-box; border:2px solid oklch(1 0 0 / .95); border-radius:50%; background:oklch(0 0 0 / .18); opacity:0; transition:opacity var(--mv-duration-fast), background-color var(--mv-duration-fast) }
#sb-demo .sb-tick::after { content:""; position:absolute; left:4px; top:1px; width:4px; height:8px; border:solid oklch(1 0 0); border-width:0 2px 2px 0; rotate:45deg; opacity:0 }
#sb-demo .sb-grid li:hover .sb-tick, #sb-demo .sb-grid li:focus-visible .sb-tick, #sb-demo mv-snowball[data-ready] .sb-tick, #sb-demo mv-snowball[data-has-selection] .sb-tick { opacity:1 }
#sb-demo .sb-grid li[aria-selected="true"] .sb-tick { opacity:1; border-color:var(--mv-accent); background:var(--mv-accent) }
#sb-demo .sb-grid li[aria-selected="true"] .sb-tick::after { opacity:1 }
#sb-demo .sb-controls { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; margin-top:1rem; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
#sb-demo .sb-field { display:flex; align-items:center; gap:.625rem; font-size:.8125rem; color:var(--mv-fg-muted) }
#sb-demo .sb-log { flex:1 1 14rem; min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:end }
/* File picker */
#sb-files { --mv-snowball-outline:none }
#sb-demo .sb-files { margin:0; padding:.375rem .5rem 0; list-style:none }
#sb-demo .sb-file label { display:grid; grid-template-columns:auto auto minmax(0,1fr) 4.5rem 6.5rem; align-items:center; gap:.75rem; padding:.5625rem .75rem; border-radius:var(--mv-radius-md); font-size:.8125rem; cursor:default; transition:background-color var(--mv-duration-fast) }
#sb-demo .sb-file label:hover { background:var(--mv-bg-muted) }
#sb-demo .sb-file[data-selected] label { background:var(--mv-accent-subtle) }
#sb-demo .sb-file svg { width:1.25rem; height:1.25rem }
#sb-demo .sb-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500 }
#sb-demo .sb-meta, #sb-demo .sb-date { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums; text-align:end; white-space:nowrap }
#sb-demo .sb-files-foot { margin:0; padding:.25rem 1.25rem 1rem; color:var(--mv-fg-subtle); font-size:.75rem; min-height:1rem }
@media (max-width:40rem) {
#sb-demo .sb-file label { grid-template-columns:auto auto minmax(0,1fr) 4.5rem }
#sb-demo .sb-date { display:none }
}
</style>
<!-- 1. Photo library: Shift + drag rolls through the grid, the ball drops into a tray of bulk actions -->
<section class="sb-card" aria-labelledby="sb-photos-title">
<header class="sb-head">
<span class="sb-title">
<strong id="sb-photos-title">Iceland · Ring Road</strong>
<span>Sep 12-19, 2026 · 18 photos</span>
</span>
<p class="sb-hint"><kbd class="mv-kbd">Shift</kbd> + drag to roll across photos <i>·</i> <kbd class="mv-kbd">Alt</kbd> drops <i>·</i> long-press on touch</p>
</header>
<mv-snowball id="sb-photos" items="[role='option']">
<ul class="sb-grid" role="listbox" aria-labelledby="sb-photos-title">
<li role="option" data-value="IMG_4101" data-size="4.2" aria-selected="false"><img alt="Harbor at dawn, Reykjavík" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4102" data-size="3.8" aria-selected="true"><img alt="Black sand beach under cloud" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4103" data-size="5.1" aria-selected="false"><img alt="Glacier lagoon with floating ice" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4104" data-size="6.4" aria-selected="false"><img alt="Northern lights over the hills" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4105" data-size="3.3" aria-selected="false"><img alt="Lava field in morning haze" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4106" data-size="4.7" aria-selected="true"><img alt="Fjord road along the water" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4107" data-size="3.9" aria-selected="false"><img alt="Geothermal pool and steam" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4108" data-size="4.4" aria-selected="false"><img alt="Basalt cliffs at low tide" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4109" data-size="5.6" aria-selected="false"><img alt="Sunset over the western coast" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4110" data-size="4.0" aria-selected="false"><img alt="Waterfall in a green valley" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4111" data-size="3.6" aria-selected="false"><img alt="Highland ridges at dusk" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4112" data-size="4.9" aria-selected="true"><img alt="Sea cliffs and green slopes" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4113" data-size="5.2" aria-selected="false"><img alt="City lights at blue hour" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4114" data-size="6.1" aria-selected="false"><img alt="Inside a blue ice cave" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4115" data-size="3.4" aria-selected="false"><img alt="Empty road through golden plains" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4116" data-size="5.8" aria-selected="false"><img alt="Volcano glowing at night" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4117" data-size="3.7" aria-selected="false"><img alt="Moss-covered lava rocks" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
<li role="option" data-value="IMG_4118" data-size="4.5" aria-selected="false"><img alt="Iceberg on a calm bay" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
</ul>
<button slot="actions" type="button" class="mv-button" data-variant="outline" data-size="sm" data-snowball-action="download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>
Download
</button>
<button slot="actions" type="button" class="mv-button" data-variant="outline" data-size="sm" data-snowball-action="album">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16v12H4zM12 9v6M9 12h6"/></svg>
Add to album
</button>
<button slot="actions" type="button" class="mv-button" data-variant="destructive" data-size="sm" data-snowball-action="delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
Delete
</button>
</mv-snowball>
<div class="sb-controls">
<span class="sb-field">
<span id="sb-mode-label">Sweep</span>
<mv-segmented id="sb-mode" aria-labelledby="sb-mode-label" value="collect">
<button value="collect">Collect</button>
<button value="paint">Paint</button>
</mv-segmented>
</span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="sb-play">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 5v14l11-7z"/></svg>
Replay a sweep
</button>
<p class="sb-log" id="sb-log">3 photos selected · 13.4 MB</p>
</div>
</section>
<!-- 2. File picker: modifier="none", any drag across rows picks them; checkboxes stay the source of truth -->
<section class="sb-card" aria-labelledby="sb-files-title">
<header class="sb-head">
<span class="sb-title">
<strong id="sb-files-title">Attach from Drive</strong>
<span>Shared › Q3 Planning · 8 files</span>
</span>
<p class="sb-hint">Drag straight down the rows to pick several <i>·</i> <kbd class="mv-kbd">Space</kbd> toggles</p>
</header>
<mv-snowball id="sb-files" modifier="none" items=".sb-file" label="Files to attach">
<ul class="sb-files" aria-labelledby="sb-files-title">
<li class="sb-file" data-value="q3-roadmap" data-size="2.4"><label><input type="checkbox" class="mv-checkbox" checked><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-danger)"><path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M9 13h6M9 17h4"/></svg><span class="sb-name">Q3 roadmap.pdf</span><span class="sb-meta">2.4 MB</span><span class="sb-date">Sep 18, 2026</span></label></li>
<li class="sb-file" data-value="budget" data-size="0.8"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-success)"><path d="M4 4h16v16H4zM4 10h16M4 15h16M10 4v16"/></svg><span class="sb-name">Budget forecast FY27.xlsx</span><span class="sb-meta">812 KB</span><span class="sb-date">Sep 16, 2026</span></label></li>
<li class="sb-file" data-value="kickoff" data-size="14.6"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-warning)"><path d="M3 5h18v12H3zM8 21h8M12 17v4"/></svg><span class="sb-name">Kickoff deck, Lisbon offsite.pptx</span><span class="sb-meta">14.6 MB</span><span class="sb-date">Sep 12, 2026</span></label></li>
<li class="sb-file" data-value="research" data-size="1.2"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-info)"><path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M9 12h6M9 15h6M9 18h3"/></svg><span class="sb-name">User research synthesis.docx</span><span class="sb-meta">1.2 MB</span><span class="sb-date">Sep 9, 2026</span></label></li>
<li class="sb-file" data-value="hiring" data-size="0.3"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-success)"><path d="M4 4h16v16H4zM4 10h16M4 15h16M10 4v16"/></svg><span class="sb-name">Hiring plan (Berlin & Toronto).xlsx</span><span class="sb-meta">296 KB</span><span class="sb-date">Sep 5, 2026</span></label></li>
<li class="sb-file" data-value="wireframes" data-size="8.9"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-accent)"><path d="M4 5h16v14H4z"/><path d="m4 16 5-5 4 4 2-2 5 5"/><circle cx="15.5" cy="9" r="1.5"/></svg><span class="sb-name">Checkout wireframes v4.png</span><span class="sb-meta">8.9 MB</span><span class="sb-date">Aug 29, 2026</span></label></li>
<li class="sb-file" data-value="legal" data-size="0.5"><label><input type="checkbox" class="mv-checkbox" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-fg-subtle)"><path d="M6 11V8a6 6 0 0 1 12 0v3M5 11h14v10H5z"/></svg><span class="sb-name">Vendor contract (restricted).pdf</span><span class="sb-meta">488 KB</span><span class="sb-date">Aug 21, 2026</span></label></li>
<li class="sb-file" data-value="retro" data-size="0.2"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-info)"><path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M9 12h6M9 15h6M9 18h3"/></svg><span class="sb-name">Sprint 38 retro notes.docx</span><span class="sb-meta">174 KB</span><span class="sb-date">Aug 14, 2026</span></label></li>
</ul>
<button slot="actions" type="button" class="mv-button" data-size="sm" data-snowball-action="attach" id="sb-attach">Attach 1 file</button>
</mv-snowball>
<p class="sb-files-foot" id="sb-files-foot"></p>
</section>
<script type="module">
const photos = document.getElementById("sb-photos");
const files = document.getElementById("sb-files");
const grid = photos.querySelector(".sb-grid");
const log = document.getElementById("sb-log");
const foot = document.getElementById("sb-files-foot");
const attach = document.getElementById("sb-attach");
// Stylized landscape "photos", generated so the demo needs no network.
const SCENES = [
["#f6c28b", "#fbe6cc", "#fff4dc", "#7d8fb0", "#51658a", "#2b3a55"],
["#c9d3df", "#eef2f6", "#ffffff", "#98a3b2", "#5a626e", "#1f2227"],
["#9fd3e6", "#e7f6fb", "#ffffff", "#cde8f2", "#7fb8cf", "#2f6b85"],
["#0b1a2e", "#1d5a52", "#c8f7df", "#143545", "#0d2533", "#050f18"],
["#d8d0c2", "#f2ede3", "#ffffff", "#8c8172", "#5a5046", "#2e2823"],
["#7fa7c9", "#d6e6f2", "#fff8e7", "#5f86a3", "#3d6280", "#203d55"],
["#b7d7cf", "#eaf5f1", "#ffffff", "#80bfb1", "#4f9486", "#28574d"],
["#98a7b6", "#dfe5ea", "#f4f6f8", "#6c7885", "#3e4751", "#1b2026"],
["#f09a66", "#fbd9a7", "#fff0c2", "#b0607a", "#7a3b5f", "#3d1f3a"],
["#a9c6b5", "#e3efe6", "#ffffff", "#6f9a7f", "#446b55", "#22392c"],
["#c5b3d8", "#efe6f5", "#fff6ea", "#9b86b3", "#6c5a88", "#3a2f52"],
["#86b6d6", "#dcecf5", "#ffffff", "#5b8f6c", "#34624a", "#17331f"],
["#2f3570", "#df8a6d", "#ffd3a3", "#5a4a7a", "#3a2f5a", "#1c1733"],
["#5fb0d6", "#c9ecf7", "#eafaff", "#3e8fb8", "#23698f", "#0f3f5c"],
["#e8c07a", "#f6e7c4", "#fffbe9", "#b98b52", "#7c5a36", "#3f2e1d"],
["#4b3b47", "#c9826a", "#ffb070", "#3b2f36", "#241c22", "#120d10"],
["#a8c3a0", "#e6efe1", "#ffffff", "#7aa06b", "#4f7545", "#2a4526"],
["#d6e5ef", "#f5f9fc", "#ffffff", "#b5d3e4", "#86b1ca", "#4a7d9c"],
];
const scene = ([s1, s2, sun, m1, m2, m3], seed) => {
let s = seed * 7919 + 17;
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
const ridge = (base, amp) => {
let d = `M0 300L0 ${base}`;
for (let x = 0; x <= 400; x += 40) d += `L${x} ${(base - rnd() * amp).toFixed(0)}`;
return `${d}L400 300Z`;
};
const sx = 60 + rnd() * 280;
const sy = 60 + rnd() * 60;
return `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300' preserveAspectRatio='xMidYMid slice'><defs><linearGradient id='s' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='${s1}'/><stop offset='1' stop-color='${s2}'/></linearGradient><radialGradient id='g'><stop offset='0' stop-color='${sun}' stop-opacity='.85'/><stop offset='1' stop-color='${sun}' stop-opacity='0'/></radialGradient></defs><rect width='400' height='300' fill='url(#s)'/><circle cx='${sx}' cy='${sy}' r='90' fill='url(#g)'/><circle cx='${sx}' cy='${sy}' r='22' fill='${sun}'/><path d='${ridge(190, 70)}' fill='${m1}'/><path d='${ridge(225, 50)}' fill='${m2}'/><path d='${ridge(262, 30)}' fill='${m3}'/></svg>`;
};
grid.querySelectorAll("img").forEach((img, i) => {
img.src = `data:image/svg+xml,${encodeURIComponent(scene(SCENES[i % SCENES.length], i + 3))}`;
});
const pristine = [...grid.children].map((li) => li.cloneNode(true));
const initial = photos.value.length ? photos.value : ["IMG_4102", "IMG_4106", "IMG_4112"];
const mb = (items) => items.reduce((sum, el) => sum + Number(el.dataset.size || 0), 0).toFixed(1);
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
const status = () => { const sel = photos.selected; log.textContent = `${plural(sel.length, "photo")} selected · ${mb(sel)} MB`; };
photos.addEventListener("mv-select-change", (e) => {
const { added, removed, source } = e.detail;
const delta = [added.length && `+${added.length}`, removed.length && `−${removed.length}`].filter(Boolean).join(" ");
status();
if (source === "sweep" && delta) log.textContent = `Sweep ${delta} · ${log.textContent}`;
});
photos.addEventListener("mv-sweep-update", status); // live, while the sweep is still rolling
photos.addEventListener("mv-sweep-end", (e) => { if (e.detail.canceled) log.textContent = `Sweep canceled · ${plural(photos.selected.length, "photo")} selected`; });
photos.addEventListener("mv-action", (e) => {
const { action, selected } = e.detail;
const what = `${plural(selected.length, "photo")} (${mb(selected)} MB)`;
if (action === "download") log.textContent = `Preparing a zip of ${what}…`;
if (action === "album") { log.textContent = `Added ${plural(selected.length, "photo")} to “Best of Iceland”.`; photos.clear(); }
if (action === "delete") { selected.forEach((li) => li.remove()); log.textContent = `Moved ${what} to Recently deleted.`; }
});
document.getElementById("sb-mode").addEventListener("mv-change", (e) => { photos.mode = e.detail.value; });
// Replay: a scripted Shift + drag through the grid, with real pointer events.
let playing = false;
const play = async () => {
if (playing || photos.sweeping) return;
playing = true;
if (grid.children.length !== pristine.length) grid.replaceChildren(...pristine.map((li) => li.cloneNode(true)));
photos.value = initial;
status();
await new Promise(requestAnimationFrame);
const items = [...grid.children];
const cols = Math.max(1, Math.round(grid.clientWidth / (items[0].offsetWidth + 8)));
const at = (i) => { const r = items[Math.min(i, items.length - 1)].getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
const path = [at(cols), at(cols + Math.min(3, cols - 1)), at(Math.min(4, cols - 1)), at(Math.min(5, cols - 1))];
const base = { bubbles: true, cancelable: true, composed: true, pointerId: 1, pointerType: "mouse", isPrimary: true, shiftKey: true };
const [x0, y0] = path[0];
items[cols].dispatchEvent(new PointerEvent("pointerdown", { ...base, clientX: x0, clientY: y0, button: 0, buttons: 1 }));
const legs = path.slice(1).map((p, i) => [path[i], p]);
for (const [[ax, ay], [bx, by]] of legs) {
const steps = 22;
for (let s = 1; s <= steps; s++) {
await new Promise(requestAnimationFrame);
const k = s / steps;
const x = ax + (bx - ax) * k;
const y = ay + (by - ay) * k + Math.sin(k * Math.PI) * 10;
(document.elementFromPoint(x, y) || document).dispatchEvent(new PointerEvent("pointermove", { ...base, clientX: x, clientY: y, button: -1, buttons: 1 }));
}
}
await new Promise((r) => setTimeout(r, 180));
const [xe, ye] = path.at(-1);
window.dispatchEvent(new PointerEvent("pointerup", { ...base, clientX: xe, clientY: ye, button: 0, buttons: 0 }));
playing = false;
};
document.getElementById("sb-play").addEventListener("click", play);
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
setTimeout(play, 450);
}, { threshold: 0.6 });
io.observe(grid);
// File picker: the tray's action reflects the selection.
const sync = () => {
const sel = files.selected;
attach.textContent = sel.length ? `Attach ${plural(sel.length, "file")} · ${mb(sel)} MB` : "Attach";
};
files.addEventListener("mv-select-change", sync);
files.addEventListener("mv-sweep-update", sync); // live, while the sweep is still rolling
customElements.whenDefined("mv-snowball").then(sync);
files.addEventListener("mv-action", (e) => {
foot.textContent = `${plural(e.detail.selected.length, "file")} attached to your message.`;
files.clear();
sync();
});
</script>
</div>Referência cultural
Katamari Damacy, Keita Takahashi (Namco) (2004, jogo). Você rola uma bola grudenta pelo mundo, tudo o que ela toca gruda nela, e quanto maior ela fica, maiores as coisas que consegue pegar. Na interface, passar o ponteiro por uma grade gruda cada item tocado em uma bola de seleção que cresce, com um alcance que aumenta junto, e depois despeja toda a coleta em uma bandeja de ações em massa.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
items | CSS selector | [data-value] | Which descendants are selectable. Items get data-snowball-item and data-selected (styling hooks). Give them role="option" (in a listbox), "gridcell" or "row" so aria-selected is meaningful, or put an <input type="checkbox"> inside: then the checkbox is the state and the focus target. Rescanned automatically when the DOM changes. |
modifier | shift | alt | ctrl | meta | mod | none | shift | Key to hold for a mouse or pen sweep (mod = Cmd on Apple, Ctrl elsewhere). With the key held, a press anywhere in the collection starts a sweep and a click without movement toggles the item. "none" makes any drag that starts on an item a sweep while plain clicks pass through (pure picker surfaces). Touch always uses long-press. |
drop-modifier | alt | shift | ctrl | meta | mod | none | alt | Key that inverts the sweep while held: rolling over items drops them from the selection. Can be pressed or released mid-sweep. Ignored when equal to modifier. |
mode | collect | paint | collect | collect: a sweep always adds (drop-modifier removes). paint: the first item decides, so starting on a selected item deselects everything rolled over. This is the natural way to drop items on touch, where there is no modifier key. |
hold | number (ms) | 450 | Long-press delay before a touch turns into a sweep. Moving more than 8px before that is treated as a scroll. |
reach | number (px) | 12 | Extra pick-up radius around the pointer when the ball is at full size. It grows with the selection from 0, so a bigger ball catches items it only brushes. 0 disables it. |
scroll-edge | number (px) | 56 | Size of the edge zones of the nearest scroll container (or the viewport) where a sweep auto-scrolls, faster near the edge. Items that scroll under a still pointer are collected too. 0 disables auto-scroll. |
tray | auto | always | none | auto | The sticky selection tray: shown while something is selected (auto), always, or never (bring your own toolbar and listen to mv-select-change). |
keyboard | on | off | on | on: roving tabindex over the items (or their checkboxes) with arrow keys (2D in grids, based on real geometry), Home/End, Shift + arrows roll, Space toggles, Ctrl/Cmd+A selects all, Escape clears. off: the app manages focus. Escape still cancels a sweep. |
label | string | Selection | Accessible name of the tray region. |
data-sweeping / data-ready / data-has-selection | set by the component | On the host: data-sweeping="add | remove" during a sweep, data-ready while the modifier is held over it (cursor hint), data-has-selection when at least one item is selected. On items: data-snowball-stuck="add | remove" for items changed by the current sweep. |
Properties
| Name | Tipo | Description |
|---|---|---|
selected | Element[] | Selected items in document order (read-only). |
value | string[] | data-value (else id) of the selected items. Setting it selects exactly those items (can be set before the element is defined). Does not emit mv-select-change. |
sweeping | boolean | True while a sweep is in progress (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement: label, count ({n}), selectAll, clear, hintPointer ({modifier}, {drop}), hintFree, hintTouch, hintKeys, announceAdded / announceRemoved / announceBoth ({added}, {removed}, {n}), announceNone, announceCancel, announceCount, announceAll, announceCleared. English defaults. |
items / modifier / dropModifier / mode / hold / reach / scrollEdge / tray / keyboard / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
select(target, on = true) | Selects (or deselects with false) an element, a value or a list of them. Returns the items that changed. Programmatic changes do not emit mv-select-change. |
toggle(target) | Toggles one item (element or value); returns its new state, or null if it is not an item. |
selectAll() / clear() | Selects every enabled item, or deselects everything. Silent (no event). |
refresh() | Rescans items and re-measures them after a layout change the component cannot observe (e.g. a transform animation on an ancestor). |
Events
| Name | Description |
|---|---|
mv-sweep-start | Cancelable, before a sweep begins. detail: { source: "mouse" | "pen" | "touch" | "keyboard", mode: "add" | "remove", origin (item or null) }. preventDefault() vetoes the sweep (e.g. while a bulk operation is running). |
mv-select-change | The selection changed through the user: once per sweep (net result, not per item), per toggle, per checkbox change, per tray or keyboard Select all / Clear. detail: { selected, values, added, removed, source: "sweep" | "toggle" | "checkbox" | "keyboard" | "tray" }. |
mv-sweep-update | Live, during a sweep: one item just stuck to the ball or peeled off (not committed yet: Escape can still cancel). detail: { item, selected, count, mode }. For live labels, sounds or haptics; persist on mv-select-change instead. |
mv-sweep-end | A sweep finished. detail: { source, added, removed, canceled, selected, values }. A canceled sweep (Escape) has restored the previous selection and emits no mv-select-change. |
mv-action | A tray action with data-snowball-action was clicked. detail: { action, selected, values }. |
Content structure
| Name | Description |
|---|---|
(content) | The collection: any markup containing the items (listbox, grid, ul, table…). |
actions | Children with slot="actions" are moved into the tray between the count and Select all / Clear. Give them data-snowball-action="<name>" to receive mv-action with the current selection, or handle their clicks yourself and read .selected. |
CSS classes
| Name | Description |
|---|---|
mv-snowball-layer | Top-layer overlay (popover="manual", aria-hidden, pointer-events: none) holding the ball while it is visible. data-mode="add | remove", data-reduced, data-source. |
mv-snowball-ball / -core / -orbit / mv-snowball-count / mv-snowball-sign | The ball: an anchor that trails the pointer (above the finger on touch), the shaded sphere (scaled by the selection size) and the container of stuck thumbnails. The count badge rides on one shoulder and becomes the whole chip under reduced motion; the minus sign on the other shoulder shows while the sweep drops items. |
mv-snowball-thumb / mv-snowball-ghost / mv-snowball-letter | Thumbnails on the ball, taken from data-snowball-thumb, the item's first <img>, its first <svg>, or else its initial letter. data-back when on the far side of the ball; -ghost while flying in or out. |
mv-snowball-tray | The sticky tray region: -count (with -ball, the count badge), -actions (slotted buttons), -end (-button Select all, -button[data-icon] Clear). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-snowball-color | var(--mv-accent) | Ball, count badge and selected outline color. |
--mv-snowball-drop-color | var(--mv-fg-subtle) | Ball color while dropping items (drop-modifier or paint from a selected item). |
--mv-snowball-outline | 2px solid var(--mv-snowball-color) | Default outline of selected items; set to none when your items style [data-selected] or [aria-selected="true"] themselves. |
--mv-snowball-outline-offset | -2px | Offset of that outline (negative draws it over the item's content). |
--mv-snowball-thumb-size | 1.375rem | Size of the thumbnails stuck on the ball. |
--mv-snowball-tray-offset | var(--mv-space-4) | Distance between the sticky tray and the bottom of the viewport or scroll container. |
Accessibility
Selection stays in the platform's own semantics: items are options, gridcells or rows with aria-selected (set explicitly to true or false, and aria-multiselectable is added to a listbox or grid container), or they contain a real checkbox whose checked state, input and change events stay the source of truth. The sweep is never pointer-only. With the keyboard, a roving tabindex puts one item (or its checkbox) in the tab order. Arrow keys move by real geometry (Left/Right in reading order and RTL-aware, Up/Down to the nearest item of the next row, nothing sideways in a vertical list), Home/End jump. Shift + arrows rolls the same ball through the items, going back over the path deselects what it had just picked up, and releasing Shift ends the sweep. Space toggles one item, Ctrl/Cmd+A selects all, and Escape cancels a sweep in progress or clears the selection. A visually hidden hint, linked to the collection with aria-describedby, explains the modifier, long-press and keys. Nothing is announced per item: a single polite status message at the end of a sweep (“8 added. 12 selected.”, “Sweep canceled. 4 selected.”), a debounced count after single toggles, and messages for Select all and Clear. The ball, its thumbnails and flying copies live in an aria-hidden, pointer-events: none top layer and are purely decorative. The count is always also in the tray, a labelled region whose Clear button has an accessible name and keeps focus safe (focus returns to the active item when the tray hides). The click that ends a pointer sweep is swallowed so links and item handlers do not fire by accident. Touch: long-press (no callout or context menu) then drag, with auto-scroll at the edges, so selection never needs precise taps. Reduced motion (OS setting or data-motion="reduce"): no rolling ball, no orbit, no flying thumbnails or tugs, just a counter chip beside the pointer. Forced colors: Highlight / HighlightText for the ball, the count badge and the selected outline.