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-20 py-10">
<wrapper-swarm :ref="swarmRefList.set">
<div class="text-4xl font-bold">
{{ t('title') }}
</div>
</wrapper-swarm>
<wrapper-swarm :ref="swarmRefList.set">
<img
src="/low/profile.webp"
alt=""
class="w-40 border rounded-full object-cover"
>
</wrapper-swarm>
<wrapper-swarm :ref="swarmRefList.set">
<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>
</wrapper-swarm>
</div>
</template>
<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import { useData } from 'vitepress'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperSwarm from '../wrapper-swarm.vue'
const { t } = useI18n()
const data = useData()
const swarmRefList = useTemplateRefsList<
InstanceType<typeof WrapperSwarm>
>()
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
swarmRefList.value.forEach((swarmRef) => {
swarmRef.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>How It Works
Photographing the DOM First
snapdom captures the slot content into a canvas, getImageData samples it point by point, and only opaque pixels spawn particles while the original DOM fades to transparent.
The big difference from Swarm Text is color. Text carries one color, but a wrapper may swallow a colorful card, a photo, or a gradient, so every particle has to remember the RGB of its source pixel, held in an extra texture.
The sampling gap is measured in device pixels, one particle per physical screen pixel by default. Measuring it in logical pixels would throw away half the detail on a Retina display for nothing.
There's a practical problem too. Wrapped content can be any size, and a full-bleed block at the default spacing would spawn over a million particles, so maxParticleCount acts as a brake and resamples at a wider gap whenever the count overshoots.
That brake reads the real particle count rather than an estimate from width and height. Text on a transparent background only spawns particles along its strokes, and billing it for the whole rectangle's area would needlessly thin it out.
Handing the Simulation to the GPU
Particle state lives in floating-point textures, with the four RGBA channels holding position and velocity, and two textures taking turns as read and 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, with the CPU never getting involved.
Turbulence comes from curl noise. Recomputing FBM every frame is wasteful, so a 256×256 lookup texture is generated on the GPU once at startup and merely sampled from then on.
Making the Swarm Feel Alive
Three forces hit the 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.
If every particle returned along a straight line, the result would look like a marching formation rather than insects. So particles farther from home take an extra dose of low-frequency noise, which nudges neighbors the same way and naturally forms little clusters.
Every particle also carries its own random factor, giving friction, force, and return rate slight variations so the swarm never moves in lockstep.
Source
API
Props
interface Props {
/** 關閉後回到原本 DOM,不生成任何粒子。@default true */
enabled?: boolean;
/**
* 粒子取樣間距,單位為裝置像素。
*
* 1 代表一顆粒子對應螢幕上一個實體像素,靜止時與原內容幾乎無異。
* 調大則蟲群變稀疏、效能變好,還原度也跟著下降。
*
* @default 1
*/
particleGap?: number;
/**
* 粒子邊長相對取樣間距的倍率。
*
* 1 代表剛好貼齊取樣格,小於 1 會露出縫隙,
* 除非刻意想要顆粒感,否則不建議調小。
*
* @default 1.2
*/
particleSize?: number;
/**
* 散開時的粒子邊長(px)。
*
* 貼齊取樣格的粒子只有一兩個裝置像素,飄出去後幾乎看不見,
* 因此散開的粒子另外給尺寸,實際值不會小於靜止時的邊長。
*
* @default 2.5
*/
scatterParticleSize?: number;
/**
* 粒子數量上限,超過時自動放大取樣間距(邊長會跟著等比放大)。
*
* 包裹的內容可大可小,沒有上限的話,滿版內容會一口氣生出上百萬顆粒子。
*
* @default 250000
*/
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;
}