元素包裝器 wrapper
包起來的內容會結冰!燒毀!(「・ω・)「
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Babylon.js | 3D 引擎 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| SDF (Signed Distance Field) | 有號距離場,用數學函式描述形狀輪廓,常用於 Shader 中繪製平滑圖形 |
| Stable Fluids | Jos Stam 提出的即時流體解法,平流、外力、壓力投影輪流跑,數值再大也不會爆掉 |
| Noise | 比 Math.random 更自然隨機效果,常用於地形、雲朵、材質等 |
| Voronoi | 細胞噪聲,把平面切成一格格不規則細胞,冰塊裡的裂紋就是這樣來的 |
| Fresnel | 視線越貼近表面反射越強的現象,冰塊、玻璃邊緣發亮、正面透明就是它 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
| Bloom | 泛光,把畫面裡超亮的部分抽出來模糊後加回去,發光體周圍才會暈開 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
使用範例
基本用法
把 element 設成 fire 或 ice 就會套上效果。

鱈魚
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。(´;ω;`)
查看範例原始碼
vue
<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>表單範例
沒填完,按鈕會凍結。(´,,•ω•,,)
查看範例原始碼
vue
<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.nickname"
type="text"
autocomplete="off"
:label="t('暱稱 *')"
:placeholder="t('至少 {count} 個字', { count: MIN_NICKNAME_LENGTH })"
class="w-full"
/>
<div class="mt-3 flex justify-center">
<wrapper-elemental
ref="wrapperRef"
: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 { 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 WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()
const data = useData()
const MIN_NICKNAME_LENGTH = 2
const wrapperRef = useTemplateRef<InstanceType<typeof WrapperElemental>>('wrapperRef')
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
wrapperRef.value?.refresh()
})
const form = ref({
nickname: '',
})
/** 暱稱字數不足,按鈕就凍在冰塊裡 */
const unfinished = computed(() => form.value.nickname.length < MIN_NICKNAME_LENGTH)
const isSubmitted = ref(false)
function handleSubmit() {
// 冰塊只是畫面,底下的按鈕還點得到,送出前要再檢查一次
if (unfinished.value) {
return
}
isSubmitted.value = true
}
function reset() {
isSubmitted.value = false
form.value = {
nickname: '',
}
}
</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>火熱集資中
客戶:「商品要營造火熱的感覺。」
鱈魚:「好。」
熱門專案水中筆電
防水 100 公尺,鍵盤專為魚鰭設計,泡在水裡也能寫程式。
NT$1,200目標 NT$1,000,000
0.12%達成率
3贊助人數
2剩餘天數
查看範例原始碼
vue
<template>
<div class="w-full flex justify-center px-4 pb-10 pt-32">
<div class="relative max-w-[20rem] w-full">
<!-- 外框墊在卡片底下,只有邊線是燃料,火沿著卡片外圍大肆燃燒 -->
<div
v-if="cardSize.height > 0"
class="pointer-events-none absolute left-0 top-0"
>
<wrapper-elemental
ref="frameRef"
:element="fireElement"
:intensity="1.1"
>
<div
class="frame"
:style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }"
/>
</wrapper-elemental>
</div>
<!-- 卡片本身也燒,火勢壓低,只冒小火,內容才看得清楚 -->
<wrapper-elemental
ref="cardWrapperRef"
:element="fireElement"
:intensity="0.2"
>
<div
ref="cardRef"
class="project flex flex-col items-center rounded-xl px-5 py-7"
>
<span class="badge rounded-full px-2.5 py-0.5 text-xs font-bold">
{{ t('badge') }}
</span>
<span class="title mt-2 text-center text-4xl font-black leading-tight">
{{ t('title') }}
</span>
<div class="description mt-2 max-w-60 text-center text-xs leading-relaxed">
{{ t('description') }}
</div>
<div class="mt-6 max-w-60 w-full flex flex-col items-center">
<span class="raised text-3xl font-bold">
NT${{ numberFormat.format(raised) }}
</span>
<span class="muted mt-0.5 text-xs">
{{ t('goal') }} NT${{ numberFormat.format(GOAL) }}
</span>
<div
class="track mt-3 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="rate"
:aria-label="t('rate')"
>
<div
class="fill h-full rounded-full"
:style="{ width: `max(${rate}%, 0.25rem)` }"
/>
</div>
<div class="grid grid-cols-3 mt-3 w-full text-center">
<div
v-for="stat in statList"
:key="stat.label"
class="flex flex-col"
>
<span class="stat-value text-base font-bold">
{{ stat.value }}
</span>
<span class="muted text-xs">
{{ stat.label }}
</span>
</div>
</div>
</div>
<button
type="button"
class="back-btn mt-6 rounded-full px-8 py-2 text-base font-bold"
@click="back"
>
{{ t('back') }}
</button>
</div>
</wrapper-elemental>
</div>
</div>
</template>
<script setup lang="ts">
import type { ElementalType } from '../type'
import { useElementSize, useElementVisibility, useMediaQuery, useMouseInElement, whenever } from '@vueuse/core'
import { useData } from 'vitepress'
import { computed, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()
const data = useData()
/** 目標一百萬、已募一千二,每次贊助一百 */
const GOAL = 1_000_000
const INITIAL_RAISED = 1200
const BACK_AMOUNT = 100
const INITIAL_BACKERS = 3
const DAYS_LEFT = 2
/** 滑鼠離卡片多近(px)就起火 */
const IGNITE_DISTANCE = 60
const numberFormat = new Intl.NumberFormat('en-US')
const backCount = ref(0)
const raised = computed(() => INITIAL_RAISED + backCount.value * BACK_AMOUNT)
const rate = computed(() => (raised.value / GOAL * 100).toFixed(2))
const statList = computed(() => [
{ label: t('rate'), value: `${rate.value}%` },
/** 同一個人贊助幾次都只算一位 */
{ label: t('backers'), value: INITIAL_BACKERS + (backCount.value > 0 ? 1 : 0) },
{ label: t('daysLeft'), value: DAYS_LEFT },
])
function back() {
backCount.value++
}
const cardRef = useTemplateRef('cardRef')
/**
* 外框跟著卡片大小。
*
* 量到尺寸才掛上外框,掛載時才不會擷取到 0×0 的空白畫面
*/
const cardSize = reactive(useElementSize(cardRef, undefined, { box: 'border-box' }))
const { elementX, elementY, elementWidth, elementHeight, sourceType } = useMouseInElement(cardRef)
/** 滑鼠離卡片邊緣多遠(px),在卡片上為 0 */
const pointerDistance = computed(() => Math.hypot(
Math.max(-elementX.value, 0, elementX.value - elementWidth.value),
Math.max(-elementY.value, 0, elementY.value - elementHeight.value),
))
const isCoarsePointer = useMediaQuery('(hover: none)')
const isCardVisible = useElementVisibility(cardRef, { threshold: 0.9 })
/**
* 滑鼠一靠近,卡片連同外圍一起燒起來,跟募資成績無關。
* 觸控裝置沒有游標,改成卡片幾乎整張進入畫面時起火
*/
const fireElement = ref<ElementalType>('none')
whenever(
() => isCoarsePointer.value
? isCardVisible.value
: sourceType.value !== null && pointerDistance.value < IGNITE_DISTANCE,
() => {
fireElement.value = 'fire'
},
{ once: true },
)
const frameRef = useTemplateRef<InstanceType<typeof WrapperElemental>>('frameRef')
const cardWrapperRef = useTemplateRef<InstanceType<typeof WrapperElemental>>('cardWrapperRef')
/**
* 贊助後數字會變,卡片快照要重拍,但這裡不必手動 refresh。
* Vue 更新文字是替換文字節點,元件監聽到節點變動會自己重拍;
* 再手動叫一次,每按一次就拍兩次,手機上會明顯卡頓。
*
* 深色模式切換發生在元件外面,元件看不到,外框與卡片都要手動重新擷取
*/
watch(() => data.isDark.value, () => {
frameRef.value?.refresh()
cardWrapperRef.value?.refresh()
})
</script>
<style scoped lang="sass">
.frame
border: 4px solid light-dark(#E5E5E5, #333)
// 火往上竄,底邊一燒就會爬進卡片、蓋住按鈕,所以只留上緣與兩側
border-bottom: none
// 與卡片的 rounded-xl 一樣大,外框才貼得上
border-radius: 0.75rem
.project
border: 1px solid light-dark(#E5E5E5, #333)
background: light-dark(#F7F7F7, #262626)
.badge
color: #FFF
background: #E5484D
.title, .raised, .stat-value
color: light-dark(#1F1F1F, #F5F5F5)
.raised, .stat-value
font-variant-numeric: tabular-nums
.description, .muted
color: light-dark(#666, #AAA)
.track
background: light-dark(#E2E2E2, #3A3A3A)
.fill, .back-btn
background: #10B981
.back-btn
color: #FFF
transition: scale 0.1s
&:active
scale: 0.97
</style>原理
整頁共用一張畫布
內容先用 snapdom 拍成貼圖,再算出一張有號距離場,火與冰塊都靠它知道輪廓在哪。內容重拍時若輪廓沒變(例如只是文字換了),距離場直接沿用,不重算。
整頁的元素共用一張鋪滿視窗的畫布、一份流體模擬與一套泛光,空氣固定在畫面上。捲動時是元素在空氣裡移動,已經竄起的火與冒出的霧留在原地,像被風吹向後方。
火
熱從輪廓冒出、受浮力往上竄,由 GPU 流體模擬出火焰的形狀,再以體積光線步進沿深度累加 3D 噪聲,畫出有前後層次的火。
真實火焰會週期性地一口一口往上吐(puffing),這裡以營火尺度約 2 Hz 為準。火會遮住內容、讓內容被熱浪扭曲,看起來才在內容前方。
冰
內容先逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊;解除時直接碎成 Voronoi 碎片飛散。
冰面旁的冷空氣變重往下流,霧在冰的底部冒得最多,隨流體往下飄、逐漸散開。
原始碼
API
Props
interface Props {
/**
* 目前附加的元素屬性。
*
* `fire` 從底部點燃,GPU 流體模擬的火焰沿著輪廓往上竄,內容受熱變亮,指標掃過會把火吹歪;
* `ice` 讓內容逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊。
* 切回 `none` 時火會先熄滅;冰若已凝固就直接碎開,還沒凝固則融化,結束後才還原成原本的 DOM。
*
* @default 'none'
*/
element?: ElementalType;
/**
* 特效強度,影響火勢、粒子數量、受熱變亮的暖色與冰殼厚度。
*
* 1 為預設外觀,2 已相當誇張。強度低於 1 時,受熱變亮的暖色與光暈跟著變淡;
* 高於 1 則維持原樣,內容才不會過曝。
*
* @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;
}