Exclusivebeta

Snowball — <mv-snowball>

Sweep selection for grids and lists (photo libraries, file managers, inboxes, catalogs): hold Shift (or any modifier, or none) and roll the pointer across the items. The path, not a rectangle, decides what gets picked, so you can snake through scattered photos without catching their neighbors. Each item it touches sticks to a selection ball that trails the pointer. The item gives a small magnetic tug, a thumbnail flies onto the ball and rolls with it in 3D as you move, and the count sits inside. The ball grows with the selection and reaches slightly further as it grows. Holding Alt while you drag deselects, and paint mode lets the first item decide. Escape cancels the whole sweep. On release the ball drops into a sticky tray holding your bulk actions (slot="actions", with an mv-action event), plus Select all and Clear. Touch uses long-press then drag, with auto-scroll at the edges of the scroll container. The keyboard is first class: Shift + arrows rolls through a grid or list, going back deselects, Space toggles, Ctrl/Cmd+A selects everything. Items keep real semantics (aria-selected on options or gridcells, or the checkbox they contain, whose input/change events still fire). Hit-testing uses cached rects folded with scroll offsets, and the ball runs in a frameLoop that sleeps when nothing moves. Prefer a rectangle lasso for free-form canvases selected by area, and plain checkboxes for a handful of items.

CategoryData display
TypeWeb Component (<mv-snowball>)
Statusbeta
Keywordsexclusive, 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

Avoid when

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["snowball"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/snowball/snowball.js, components/snowball/snowball.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<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 &amp; 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-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>

Cultural reference

Katamari Damacy — Keita Takahashi (Namco) (2004, game). You roll a sticky ball over the world, everything it touches sticks to it, and the bigger it grows the bigger the things it can pick up. In the UI, rolling the pointer across a grid sticks every item it touches onto a growing selection ball whose pick-up reach grows with it, then drops the whole haul into a tray of bulk actions.

API

Attributes

NameTypeDefaultDescription
itemsCSS 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.
modifiershift | alt | ctrl | meta | mod | noneshiftKey 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-modifieralt | shift | ctrl | meta | mod | nonealtKey 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.
modecollect | paintcollectcollect: 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.
holdnumber (ms)450Long-press delay before a touch turns into a sweep. Moving more than 8px before that is treated as a scroll.
reachnumber (px)12Extra 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-edgenumber (px)56Size 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.
trayauto | always | noneautoThe sticky selection tray: shown while something is selected (auto), always, or never (bring your own toolbar and listen to mv-select-change).
keyboardon | offonon: 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.
labelstringSelectionAccessible name of the tray region.
data-sweeping / data-ready / data-has-selectionset by the componentOn 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

NameTypeDescription
selectedElement[]Selected items in document order (read-only).
valuestring[]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.
sweepingbooleanTrue while a sweep is in progress (read-only).
stringsPartial<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 / labelreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-sweep-startCancelable, 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-changeThe 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-updateLive, 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-endA 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-actionA tray action with data-snowball-action was clicked. detail: { action, selected, values }.

Content structure

NameDescription
(content)The collection: any markup containing the items (listbox, grid, ul, table…).
actionsChildren 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

NameDescription
mv-snowball-layerTop-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-signThe 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-letterThumbnails 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-trayThe sticky tray region: -count (with -ball, the count badge), -actions (slotted buttons), -end (-button Select all, -button[data-icon] Clear).

CSS variables

NameDefaultDescription
--mv-snowball-colorvar(--mv-accent)Ball, count badge and selected outline color.
--mv-snowball-drop-colorvar(--mv-fg-subtle)Ball color while dropping items (drop-modifier or paint from a selected item).
--mv-snowball-outline2px 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-2pxOffset of that outline (negative draws it over the item's content).
--mv-snowball-thumb-size1.375remSize of the thumbnails stuck on the ball.
--mv-snowball-tray-offsetvar(--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.