エクスクルーシブ

Snowball <mv-snowball>

グリッドやリスト(写真ライブラリ、ファイルマネージャー、受信トレイ、カタログ)のためのなぞり選択です。Shift(または任意の修飾キー、あるいはキーなし)を押したまま、項目の上でポインターを転がします。

文化的リファレンス

塊魂、高橋慶太(ナムコ)(2004年、ゲーム)。粘着性のある玉を転がして世界を巡ると、触れたものすべてがくっつき、玉が大きくなるほど大きなものを巻き込めるようになります。UI では、グリッド上でポインターを転がすと、触れたアイテムがすべて成長する選択の玉にくっつき、玉とともに巻き込める範囲も広がります。最後に、集めたものをまとめて一括操作のトレイに落とします。

仕組み

グリッドやリスト(写真ライブラリ、ファイルマネージャー、受信トレイ、カタログ)のためのスイープ選択:Shift(または任意の修飾キー、あるいはなし)を押したまま、ポインターをアイテムの上で転がします。選択されるものを決めるのは矩形ではなく軌跡なので、散らばった写真の間を縫うように進んでも隣のアイテムを巻き込みません。触れた各アイテムは、ポインターの後を追う選択の玉にくっつきます。アイテムは磁石のように小さく引っ張られ、サムネイルが玉に飛び乗って、動きに合わせて 3D で一緒に転がり、玉の中には件数が表示されます。玉は選択とともに大きくなり、大きくなるにつれて少し遠くまで届くようになります。ドラッグ中に Alt を押すと選択解除になり、ペイントモードでは最初のアイテムが動作を決めます。Escape でスイープ全体をキャンセルします。離すと玉は固定表示のトレイに落ち、一括操作(slot="actions"、mv-action イベント付き)と Select all、Clear が表示されます。タッチでは長押ししてからドラッグし、スクロールコンテナの端では自動スクロールします。キーボードも完全にサポートされています:Shift + 矢印キーでグリッドやリストを転がり、戻ると選択解除、Space で切り替え、Ctrl/Cmd+A ですべてを選択します。アイテムは本来のセマンティクスを保ちます(option や gridcell の aria-selected、または内包するチェックボックス。その input/change イベントも引き続き発火します)。当たり判定にはスクロールオフセットを加味したキャッシュ済みの矩形を使い、玉は何も動いていないときにはスリープする frameLoop で動作します。面積で選択する自由形式のキャンバスには矩形の投げ縄選択を、少数のアイテムには通常のチェックボックスをおすすめします。

カテゴリーデータ表示
タイプWeb Component(<mv-snowball>)
ステータス安定版
キットパワーユーザー向けナビゲーション
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

  • 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日 where a rectangle lasso would also catc時間 the unwanted neighbors between the分
  • 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

インストール

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

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["snowball"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):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。

使い方

クイックスタート、動作する最小限のマークアップ:

<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>

基本となるマークアップです。ここから属性、data-*、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 &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-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>

API

Attributes

NameタイプDefaultDescription
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 macOS, 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

NameタイプDescription
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.

このページは AI で翻訳されています。翻訳の問題を報告