ExklusivBeta
Snowball <mv-snowball>
Wischauswahl für Raster und Listen (Fotobibliotheken, Dateimanager, Posteingänge, Kataloge): Halte Shift (oder einen beliebigen Modifikator oder keinen) und roll den Zeiger über die Elemente. Der Pfad, nicht ein Rechteck, entscheidet, was ausgewählt wird, sodass du dich durch verstreute Fotos schlängeln kannst, ohne ihre Nachbarn mitzunehmen. Jedes berührte Element bleibt an einer Auswahlkugel kleben, die dem Zeiger folgt. Das Element gibt einen kleinen magnetischen Ruck, ein Thumbnail fliegt auf die Kugel und rollt beim Bewegen in 3D mit, und die Anzahl steht darin. Die Kugel wächst mit der Auswahl und reicht beim Wachsen etwas weiter. Alt beim Ziehen gedrückt halten hebt die Auswahl auf, und der Malmodus lässt das erste Element entscheiden. Escape bricht den ganzen Zug ab. Beim Loslassen fällt die Kugel in eine Sticky-Ablage mit deinen Massenaktionen (slot="actions", mit einem mv-action-Event), dazu Select all und Clear. Touch nutzt langes Drücken und dann Ziehen, mit Auto-Scroll an den Rändern des Scroll-Containers. Die Tastatur ist vollwertig: Shift + Pfeiltasten rollt durch ein Raster oder eine Liste, Zurückgehen hebt die Auswahl auf, Space schaltet um, Ctrl/Cmd+A wählt alles aus. Elemente behalten ihre echte Semantik (aria-selected auf Options oder Gridcells oder die enthaltene Checkbox, deren input/change-Events weiterhin feuern). Die Trefferprüfung nutzt zwischengespeicherte Rects, verrechnet mit den Scroll-Offsets, und die Kugel läuft in einem frameLoop, der schläft, wenn sich nichts bewegt. Für freie Canvas-Flächen, die nach Bereich ausgewählt werden, nimm besser ein Rechteck-Lasso, und für eine Handvoll Elemente einfache Checkboxen.
| Kategorie | Datenanzeige |
|---|---|
| Typ | Web Component (<mv-snowball>) |
| Status | Beta |
| Kit | Navigation für Power-User |
| 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 T where a rectangle lasso would also catc Std 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
Installation
node scripts/add.mjs snowball --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["snowball"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): 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.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<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>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<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>Kulturelle Referenz
Katamari Damacy, Keita Takahashi (Namco) (2004, Spiel). Du rollst eine klebrige Kugel über die Welt, alles, was sie berührt, bleibt an ihr haften, und je größer sie wird, desto größere Dinge kann sie aufsammeln. In der UI klebt das Rollen des Zeigers über ein Raster jedes berührte Element an eine wachsende Auswahlkugel, deren Reichweite mit ihr wächst, und lässt dann die ganze Ausbeute in eine Ablage mit Massenaktionen fallen.
API
Attributes
| Name | Typ | 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 | Typ | 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.