Elemental Wrapper wrapper
Set the wrapped content on fire or freeze it solid. Flames and ice grow along its outline. (ノ>ω<)ノ
Tech Keywords
| Name | Description |
|---|---|
| Babylon.js | 3D engine |
| DOM to Image | Converts DOM elements into images, based on SVG foreignObject |
| SDF (Signed Distance Field) | Describes shape outlines with mathematical functions, commonly used in shaders to draw smooth graphics |
| Stable Fluids | Jos Stam's real-time fluid solver that alternates advection, forces and pressure projection, staying stable at any time step |
| Noise | Produces more natural randomness than Math.random, commonly used for terrain, clouds, and textures |
| Voronoi | Cellular noise that splits a plane into irregular cells, which is where the cracks inside the ice come from |
| Fresnel | Reflection grows stronger at grazing angles, which is why ice and glass glow at the edges and stay clear in the middle |
| Particle System | Spawns large numbers of small objects, commonly used to simulate smoke, fire, rain, and snow |
| Bloom | Extracts the brightest parts of a frame, blurs them, and adds them back so glowing things bleed light around them |
| Pointer Events | Detects pointer movement, clicks, hovers, and more, providing coordinates and target information |
Examples
Basic Usage
Set element to fire or ice to apply the effect. Switching back to none lets the fire die down, while solid ice smashes into shards, before the DOM is restored. Sweep the mouse or scroll the page to blow the flames, or the mist around the ice, aside. ᕕ( ゚ ∀。)ᕗ

View example source
<template>
<div class="w-full flex flex-col items-center gap-12 py-10">
<div class="flex flex-col items-center gap-3">
<div class="flex flex-wrap justify-center gap-3">
<base-btn
v-for="option in optionList"
:key="option.value"
:label="option.label"
:class="{ 'option--active': element === option.value }"
@click="element = option.value"
/>
</div>
<!-- 測試用:複製整頁共用畫布的效能報告,方便回報 -->
<base-btn
v-if="perfReportVisible"
:label="copied ? t('copied') : t('copyPerfReport')"
class="text-xs opacity-70"
@click="copyPerfReport"
/>
</div>
<wrapper-elemental
:ref="wrapperRefList.set"
:element="element"
>
<img
src="/low/profile.webp"
alt=""
class="w-40 border-[0.25rem] rounded-full object-cover"
>
</wrapper-elemental>
<wrapper-elemental
:ref="wrapperRefList.set"
:element="element"
>
<div class="card border rounded-lg p-6">
<div class="text-2xl font-bold">
{{ t('codfish') }}
</div>
<div class="mt-2 max-w-[17rem]">
{{ t('codfishDescription') }}
</div>
</div>
</wrapper-elemental>
</div>
</template>
<script setup lang="ts">
import type { ElementalType } from '../type'
import { useClipboard, useTemplateRefsList } from '@vueuse/core'
import { useData } from 'vitepress'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()
const data = useData()
const element = ref<ElementalType>('fire')
const optionList = computed(() => [
{ value: 'fire' as const, label: t('fire') },
{ value: 'ice' as const, label: t('ice') },
{ value: 'none' as const, label: t('none') },
])
const wrapperRefList = useTemplateRefsList<
InstanceType<typeof WrapperElemental>
>()
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
wrapperRefList.value.forEach((wrapperRef) => {
wrapperRef.refresh()
})
})
/** 效能報告按鈕平時隱藏,要量測時改成 true */
const perfReportVisible = false
const { copy, copied } = useClipboard({ legacy: true })
/** 所有元素共用一張畫布,任一個元素拿到的都是整頁的報告 */
function copyPerfReport() {
const report = wrapperRefList.value
.map((wrapperRef) => wrapperRef.getPerfReport())
.find((value) => value !== undefined)
copy(`時間:${new Date().toISOString()}\n\n${report ?? t('noPerfReport')}`)
}
</script>
<style scoped lang="sass">
.card
background: light-dark(#EEE, #333)
.option--active
background-color: light-dark(#DDD, #555)
</style>Form Example
Until the form is filled out, the button stays frozen in a block of ice; fill it out and the ice shatters so you can click. (´,,•ω•,,)
View example source
<template>
<div class="relative w-full flex justify-center py-24">
<div class="max-w-[20rem] flex flex-col gap-4">
<base-input
v-model="form.username"
:label="t('帳號 *')"
class="w-full"
/>
<base-input
v-model="form.password"
type="password"
:label="t('密碼 *')"
class="w-full"
/>
<div class="mt-3 flex justify-center">
<wrapper-elemental
:element="unfinished ? 'ice' : 'none'"
:transition-duration="800"
>
<base-btn
:label="t('登入')"
@click="handleSubmit"
/>
</wrapper-elemental>
</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('表單已送出!(*´∀`)~♥') }}
</span>
<span class="cursor-pointer text-xs">
{{ t('點一下再來一次') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()
const form = ref({
username: '',
password: '',
})
/** 表單沒填完,按鈕就凍在冰塊裡 */
const unfinished = computed(() => form.value.username === '' || form.value.password === '')
const isSubmitted = ref(false)
function handleSubmit() {
// 冰塊只是畫面,底下的按鈕還點得到,送出前要再檢查一次
if (unfinished.value) {
return
}
isSubmitted.value = true
}
function reset() {
isSubmitted.value = false
form.value = {
username: '',
password: '',
}
}
</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
One canvas for the whole page
The content is captured into a texture with snapdom, then turned into a signed distance field that tells the fire and ice where the outline is.
Every element on the page shares one viewport-sized canvas, one fluid simulation and one bloom pass, with the air pinned to the screen. When scrolling, the element moves through the air, so flames and mist already released stay behind as if blown by the wind.
Fire
Heat rises from the outline on buoyancy, shaped by a GPU fluid simulation, and the flame is drawn by volumetric ray marching through 3D noise for layered depth.
Real flames puff periodically; the campfire scale of about 2 Hz is used here. The fire covers the content and distorts it with heat haze, so it reads as sitting in front.
Ice
The content cools down, then sets into a 3D ice block that refracts the content; dispelling shatters it into flying Voronoi shards.
Cold air next to the ice sinks, so the mist pours out mostly at the bottom and drifts down with the fluid, spreading as it goes.
Source Code
API
Props
interface Props {
/**
* 目前附加的元素屬性。
*
* `fire` 從底部點燃,GPU 流體模擬的火焰沿著輪廓往上竄,內容受熱變亮,指標掃過會把火吹歪;
* `ice` 讓內容逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊。
* 切回 `none` 時火會先熄滅;冰若已凝固就直接碎開,還沒凝固則融化,結束後才還原成原本的 DOM。
*
* @default 'none'
*/
element?: ElementalType;
/**
* 特效強度,影響火勢、粒子數量與冰殼厚度。
*
* 1 為預設外觀,2 已相當誇張。
*
* @default 1
*/
intensity?: number;
/**
* 點燃、熄滅、結凍、融化各自的過渡時間(ms)。
*
* @default 1500
*/
transitionDuration?: number;
}Emits
interface Emits {
/** 舞台完成初始化、開始繪製時觸發 */
ready: [];
/** 過渡結束時觸發,帶著當時的元素屬性;收到 `none` 代表已完全還原 */
settled: [element: ElementalType];
}Methods
interface Expose {
/** 重新擷取內容圖片,內容或主題變更後呼叫 */
refresh: () => Promise<void>;
/**
* 測試用:取得效能報告文字,包含裝置、共用畫布、流體規格、頁面上每個元素的狀態與最近 300 幀的耗時。
* 效果未啟動時回傳 undefined
*/
getPerfReport: () => string | undefined;
}Slots
interface Slots {
/** 要附加元素效果的內容 */
default?: () => unknown;
}