DOM Emitter util
Can emit any web content! ੭ ˙∀˙ )੭
Example Usage
Basic Usage
The content put into the slot can be emitted as particles. If the DOM changes, the particle images will also automatically update.( •̀ ω •́ )✧
(Click on the card to switch styles)




View Source Code
<template>
<div class="w-full flex flex-col gap-4">
<util-dom-emitter
ref="emitterRef"
:slot-list="keys(slots)"
class="grid justify-items-center gap-2 md:grid-cols-2"
slot-wrapper-class=" w-2/3 md:w-full"
canvas-z-index="9999"
>
<template
v-for="(slot, name) in slots"
#[name]
:key="name"
>
<div
class="grid grid-cols-3 grid-rows-2 h-full cursor-pointer gap-2 rounded p-2 text-sm"
:class="slot.type"
@click="nextType(slot)"
>
<div class="row-span-2 flex-center">
<img
:src="slot.avatar"
class="avatar row-span-2 aspect-square shrink-0 object-cover"
@load="refreshDomImage()"
>
</div>
<span
class="col-span-2 row-span-1 whitespace-pre-line"
v-text="t(slot.valueKey)"
/>
<div class="col-span-2 row-span-1 flex">
<span
v-for="n in slot.rate"
:key="n"
class="rate-star"
/>
<span
class="flex-1 self-end text-right text-xs text-gray-300"
v-text="slot.date"
/>
</div>
</div>
</template>
</util-dom-emitter>
<div class="flex gap-2">
<base-input
v-model="emitInterval"
type="range"
:label="`${t('發射週期')} : ${emitInterval}`"
:min="1"
:max="1000"
class="flex-1 border rounded-lg p-4"
/>
<base-checkbox
v-model="enableEmit"
:label="t('開始發射')"
class="border rounded-lg p-4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { debounce } from 'lodash-es'
import { nanoid } from 'nanoid'
import { keys } from 'remeda'
import { reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
// import UtilDomEmitter from '../util-dom-emitter-2d.vue'
import UtilDomEmitter from '../util-dom-emitter.vue'
const { t } = useI18n()
const typeList = ['t1', 't2', 't3', 't4'] as const
interface SlotDatum {
avatar: string;
rate: number;
valueKey: string;
type: typeof typeList[number];
date: string;
}
const defaultSlotDatum: SlotDatum = {
avatar: '/logo.webp',
rate: 0,
valueKey: '',
type: 't1',
date: '',
}
const emitterRef = useTemplateRef('emitterRef')
const slots = reactive({
[nanoid()]: {
...defaultSlotDatum,
rate: 1,
valueKey: 'msg1',
date: '2023/10/01',
},
[nanoid()]: {
...defaultSlotDatum,
avatar: '/low/profile.webp',
rate: 5,
valueKey: 'msg2',
date: '2022/12/06',
},
[nanoid()]: {
...defaultSlotDatum,
avatar: '/low//profile-2.webp',
rate: 4,
valueKey: 'msg3',
date: '2024/04/03',
},
[nanoid()]: {
...defaultSlotDatum,
avatar: '/low//profile-3.webp',
rate: 3,
valueKey: 'msg4',
date: '2025/07/11',
},
})
function nextType(data: SlotDatum) {
const index = typeList.indexOf(data.type) + 1
data.type = typeList.at(index % typeList.length) ?? 't1'
}
/** 偵測不到圖片載入變化,手動更新 DOM 圖片 */
const refreshDomImage = debounce(() => {
emitterRef.value?.refreshDomImage()
}, 100)
const enableEmit = ref(false)
const emitInterval = ref(500)
useIntervalFn(() => {
if (!enableEmit.value) {
return
}
emitterRef.value?.emit({
quantity: 4,
x: [0, window.innerWidth],
y: window.innerHeight + 80,
vx: [-50, 50],
vy: [-300, -800],
a: 0,
va: [-0.3, 0.3],
})
}, emitInterval)
</script>
<style scoped lang="sass">
.t1
color: #444
background: #FDFDFD
border: 1px solid #DDD
.avatar
background: #c2eeff
border-radius: 50%
.rate-star
&::before
content: '★'
color: #f59e0b
.t2
border-radius: 0px
background-color: #0d9488
border: 1px solid #0d9488
color: white
.avatar
background: #99f6e4
clip-path: polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)
.rate-star
&::before
content: '🍕'
color: #0d9488
.t3
border-radius: 1rem
background-color: #fef2f2
border: 1px solid #fecaca
color: #b91c1c
.avatar
border-radius: 1rem
.rate-star
&::before
content: '❤️'
color: #b91c1c
.t4
border-radius: 999px
padding-right: 2rem
background-color: #fdf4ff
border: 4px solid #f5d0fe
color: #a855f7
.avatar
border-radius: 999px
background: #f5d0fe
.rate-star
&::before
content: '🔥'
color: #a855f7
</style>How it works
Retrieves the DOM in the slot and converts it into an image, and then uses the image as a particle for physics simulation.
With the particle data and image available, the final step is just drawing the particles in Canvas.(・∀・)9
Afterword
The first component developed using Shader makes its debut. Shooting a commemorative firework.✧⁑。٩(ˊᗜˋ*)و✧⁕。
Initially developed with Canvas 2D API, but as the number of particles increased, the performance became too tragic, so I decided to switch to Shader.
Easier said than done, Shaders really are like reading an alien language...(›´ω`‹ ), so I had to ask powerful AI to help me convert my original 2D version to Shader and explain it in detail.∠( ᐛ 」∠)_
Tried all models (Sonnet 4, GPT 4.1, GPT 4o, Gemini 2.5 Pro, GPT o3). The code generated by any single model couldn't run directly, and had various small errors that needed manual fixes.
In the end, only the code from GPT o3 could actually run. The code from other models, even after tweaking it to have zero errors, still wouldn't run properly. ( ˙꒳˙)
I truly feel AI is an excellent teacher. Otherwise, like in the past, slowly searching and watching tutorials, I wouldn't have produced a single thing, and my enthusiasm would be exhausted first.( ˘・з・)
Now we can quickly validate ideas and verify interesting concepts, but it also certainly lowered some professional barriers to a certain extent.
I can only say we are entering the best of times, and the worst of times.(´・ω・`)
2D API vs Shader
I have kept the Canvas 2D API version. You can compare it with the code of the current Shader version.
It's easy to notice that the Shader version is N times more complex than the 2D version, but the performance is also enhanced N times over. Truly quite fascinating.੭ ˙ᗜ˙ )੭
Source Code
API
Props
interface Props {
slotList: SlotList;
gravity?: number;
slotWrapperClass?: string;
canvasZIndex?: number | string;
}Methods
/** 粒子發射參數
*
* [number, number] 表示此範圍隨機值
*/
interface ParticleParams {
quantity: number;
x: number | [number, number];
y: number | [number, number];
vx: number | [number, number];
vy: number | [number, number];
/** 角度 */
a: number | [number, number];
/** 角速度 */
va: number | [number, number];
}
/** 發射特定 slot,或者所有 slot 平均 */
type EmitParams = Record<keyof SlotList, ParticleParams> | ParticleParams
interface Expose {
emit: (params: EmitParams) => void;
/** 若粒子圖片與 DOM 有落差,可以手動更新 */
refreshDomImage: () => void;
}Slots
type Slots = {
[K in SlotList[number]]: () => unknown;
}