Swarm Wrapper wrapper
Whatever you wrap turns into a swarm. Sweep the cursor across it and the particles scatter, then drift back once you leave.
Tech Keywords
| Name | Description |
|---|---|
| DOM to Image | Converts DOM elements into images, based on SVG foreignObject |
| Canvas getImageData | Reads pixel data from a specified canvas region |
| Canvas Shader | Written in GLSL and executed directly on the GPU, faster than the Canvas 2D API but harder to master |
| Curl Noise | Divergence-free vector field based on noise, producing naturally flowing particle motion |
| Particle System | Spawns large numbers of small objects, commonly used to simulate smoke, fire, rain, and snow |
| Physics Simulation | Simulates real-world physics such as gravity, collisions, and velocity |
| Vector Math | Math operations for direction, acceleration, velocity, and more |
| Pointer Events | Detects pointer movement, clicks, hovers, and more, providing coordinates and target information |
Examples
Basic Usage
Just wrap the content, nothing else to change.

View example source
<template>
<div class="example-wrap w-full flex flex-col items-center justify-center gap-10 py-10">
<wrapper-swarm ref="swarmRef">
<div class="flex flex-col items-center gap-10">
<img
src="/low/profile.webp"
alt=""
class="w-60 border rounded-full object-cover"
>
<div class="card border rounded p-6">
<div class="text-center text-xl font-bold">
{{ t('codfish') }}
</div>
<div class="mt-2 max-w-[17rem]">
{{ t('codfishDescription') }}
</div>
</div>
</div>
</wrapper-swarm>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
import { useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperSwarm from '../wrapper-swarm.vue'
const { t } = useI18n()
const data = useData()
const swarmRef = useTemplateRef<InstanceType<typeof WrapperSwarm>>('swarmRef')
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
swarmRef.value?.refresh()
})
</script>
<style scoped lang="sass">
.card
background: light-dark(#EEE, #333)
</style>Pointless Survey
A survey about whether to get drinks in the afternoon. ♪( ◜ω◝و(و
Colleague: "Those options look perfectly clickable to me! Σ(ˊДˋ;)"
View example source
<template>
<div class="w-full flex justify-center p-6">
<div class="example-wrap flex flex-col items-start gap-4 px-10">
<div class="text-xl font-bold">
{{ t('title') }}
</div>
<div class="w-full flex flex-col select-none gap-4 whitespace-nowrap">
<label class="w-fit flex items-center gap-2 text-lg">
<input
v-model="value"
type="radio"
value="yes"
class="size-6"
>
{{ t('drinkOption.yes') }}
</label>
<wrapper-swarm ref="swarmRef">
<div class="flex flex-col gap-4">
<label
v-for="optionKey in swarmOptionKeyList"
:key="optionKey"
class="w-fit flex items-center gap-2 text-lg"
>
<!--
不加 disabled,看起來就是一般選項。
蟲群舞台本身會蓋住內容並吃掉指標事件,點不到自然選不到。
-->
<input
v-model="value"
type="radio"
:value="optionKey"
class="size-6"
>
{{ t(`drinkOption.${optionKey}`) }}
</label>
</div>
</wrapper-swarm>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
import { ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperSwarm from '../wrapper-swarm.vue'
const { t } = useI18n()
const data = useData()
const swarmOptionKeyList = ['no', 'onTheHouse', 'later', 'dependsOnBrand']
const value = ref('')
const swarmRef = useTemplateRef('swarmRef')
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
swarmRef.value?.refresh()
})
</script>Form Example
Leave the form unfinished and the button is just a cloud of bugs. (╯•̀ὤ•́)╯
View example source
<template>
<div class="example-wrap relative w-full flex justify-center py-6">
<div class="max-w-[16rem] w-full flex flex-col gap-4">
<base-input
v-model="form.username"
:label="t('username')"
class="w-full"
/>
<base-input
v-model="form.password"
type="password"
:label="t('password')"
class="w-full"
/>
<div class="mt-6 flex flex-col items-center gap-3">
<!--
沒填完就開啟蟲群,按鈕化為粒子,滑鼠一靠近就散開。
蟲群舞台會蓋住內容並吃掉指標事件,不必另外 disabled 也按不到。
-->
<wrapper-swarm
ref="swarmRef"
:enabled="disabled"
>
<base-btn
:label="t('submit')"
class="whitespace-nowrap"
@click="handleSubmit"
/>
</wrapper-swarm>
</div>
</div>
<transition name="opacity">
<div
v-if="isSubmitted"
class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
@click="reset"
>
<span class="text-xl tracking-wide">
{{ t('submitted') }}
</span>
<span class="cursor-pointer text-xs">
{{ t('retry') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import WrapperSwarm from '../wrapper-swarm.vue'
const { t } = useI18n()
const data = useData()
const form = ref({
username: '',
password: '',
})
const disabled = computed(() => {
return form.value.username === '' || form.value.password === ''
})
const isSubmitted = ref(false)
function handleSubmit() {
if (disabled.value) {
return
}
isSubmitted.value = true
}
function reset() {
isSubmitted.value = false
form.value = {
username: '',
password: '',
}
}
const swarmRef = useTemplateRef('swarmRef')
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
swarmRef.value?.refresh()
})
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
</style>How It Works
Photographing the DOM First
snapdom captures the slot content into a canvas and getImageData samples it point by point; only opaque pixels spawn particles, while the original DOM fades to transparent. Unlike Swarm Text, a wrapper may swallow a colorful card, a photo, or a gradient, so every particle also stores the RGB of its source pixel.
scatterPadding expands the canvas all around to give particles room to drift. On a narrow viewport the left/right padding is clipped to the available width, so no extra horizontal scrollbar appears.
Toggling enabled cross-fades the canvas and the original DOM, and the canvas stays mounted until the fade finishes, so the swarm never blinks out.
CSS Pixels vs Device Pixels
Which kind of pixel sets the particle density is the one decision this component really hinges on, because "pixel" means two different things on the web.
Layout is written in CSS pixels: width: 120px looks about the same size on every screen. The dots that actually light up are device pixels, and the ratio between the two is devicePixelRatio. Desktops are usually 1, phones commonly 2 or 3. A ratio of 3 splits each side into three, so one CSS pixel spreads across 9 device pixels.
Both squares take up the same room on screen and the same physical size; only the subdivision differs. Pick the wrong unit for the sampling gap and the cost is multiplied by the square of that ratio.
For the same 4 × 4 CSS pixel patch of content on a phone with a ratio of 3, the two gaps spawn nine times apart.
Nine times the particles looks no better and costs far more memory and compute. So the gap is measured in CSS pixels, one particle per CSS pixel, and density follows the layout alone, costing the same on every screen.
A full-bleed block can still overshoot a million particles, so maxParticleCount acts as a brake, resampling at a wider gap whenever the count goes over.
The sampling grid still aligns to the device pixel grid, and every particle is drawn one device pixel wider than the gap so neighbors overlap. Match the gap exactly and mobile GPUs drop whole rows, each driver following its own edge rules, and the image shows horizontal seams; one extra pixel makes that impossible. Particles are opaque, so the overlap merely lets the later one cover the earlier, and sharpness still comes from the sampling gap.
Handing the Simulation to the GPU
Particle state lives in floating-point textures, the four RGBA channels holding position and velocity, with two textures taking turns as read/write target, a ping-pong. Each frame runs a physics pass to write the new state, then a render pass that looks every particle up by gl_VertexID, no CPU involved.
Turbulence comes from curl noise. A 256×256 lookup texture is built on the GPU once at startup, saving an FBM recomputation every frame.
Making the Swarm Feel Alive
Three forces hit particles near the cursor at once: a wind along the sweep direction, curl noise turbulence, and a radial push that opens a hollow at the center.
A straight-line return would look like a marching formation, so particles farther from home take an extra dose of low-frequency noise that nudges neighbors the same way, forming little clusters. Each particle also carries its own random factor, so friction, force, and return rate all vary slightly and the swarm never moves in lockstep.
Source
API
Props
interface Props {
/**
* 關閉後回到原本 DOM,不生成任何粒子。
*
* 切換時畫布與原始 DOM 交叉淡入淡出,不會瞬間斷電。
*
* @default true
*/
enabled?: boolean;
/**
* 粒子取樣間距,單位為 CSS 像素。
*
* 1 代表一顆粒子對應版面上一個 CSS 像素,各種螢幕的粒子密度與成本一致。
* 若改用裝置像素計算,同一塊內容在像素比 3 的手機上會生出九倍粒子,
* 畫面沒有更好看,記憶體與運算卻整個翻上去。
* 調大則蟲群變稀疏、效能變好,還原度也跟著下降。
*
* @default 1
*/
particleGap?: number;
/**
* 粒子邊長相對取樣間距的倍率。
*
* 1 代表剛好貼齊取樣格。實際繪製時還會再多蓋一個裝置像素,
* 因為邊長與格距相等時,行動裝置 GPU 會規律地漏掉整列而浮現橫縫,
* 詳見 swarm-stage 的繪製邏輯。
* 小於 1 會露出縫隙,大於 1 則讓相鄰粒子重疊更多,除非刻意想要顆粒感,
* 否則不建議更動。
*
* @default 1
*/
particleSize?: number;
/**
* 散開時的粒子邊長(px)。
*
* 貼齊取樣格的粒子只有一兩個裝置像素,飄出去後幾乎看不見,
* 因此散開的粒子另外給尺寸,實際值不會小於靜止時的邊長。
*
* @default 2
*/
scatterParticleSize?: number;
/**
* 粒子數量上限,超過時自動放大取樣間距(邊長會跟著等比放大)。
*
* 包裹的內容可大可小,沒有上限的話,滿版內容會一口氣生出上百萬顆粒子。
*
* 間距已改以 CSS 像素計算,粒子數只跟版面大小有關,不再隨螢幕像素比暴增,
* 因此上限多半只在桌機的滿版內容才會碰到。粒子多寡直接反映在顯示卡記憶體上,
* 100 萬顆約需 64MB,需要更省時再自行調低。
*
* @default 1000000
*/
maxParticleCount?: number;
/**
* 粒子可飄出內容範圍的距離(px)。
*
* 畫布會依此值往四周各撐大一圈,太小的話散開的粒子會直接被裁掉,
* 邊界處出現一條難看的直線。
*
* @default 150
*/
scatterPadding?: number;
/** 滑鼠擾動的影響半徑(px)。@default 60 */
scatterRadius?: number;
/** 擾動力道,越大散得越開。@default 40 */
scatterForce?: number;
/** 回歸速度,0~1 之間,越大聚回原位越快。@default 0.1 */
returnSpeed?: number;
/** 摩擦力,0~1 之間,越接近 1 慣性越強、飄得越久。@default 0.92 */
friction?: number;
}Emits
interface Emits {
/** 蟲群完成初始化、開始模擬時觸發 */
ready: [];
}Methods
interface Expose {
/** 讓蟲群立刻回到原位 */
reset: () => void;
/** 重新擷取內容圖片,內容或主題變更後呼叫 */
refresh: () => Promise<void>;
}Slots
interface Slots {
/** 要化為蟲群的內容 */
default?: () => unknown;
}