Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

Elemental Wrapper wrapper ​

Freeze the wrapped content! Burn it down! (「・ω・)「

Tech Keywords ​

NameDescription
Babylon.js3D engine
DOM to ImageConverts 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 FluidsJos Stam's real-time fluid solver that alternates advection, forces and pressure projection, staying stable at any time step
NoiseProduces more natural randomness than Math.random, commonly used for terrain, clouds, and textures
VoronoiCellular noise that splits a plane into irregular cells, which is where the cracks inside the ice come from
FresnelReflection grows stronger at grazing angles, which is why ice and glass glow at the edges and stay clear in the middle
Particle SystemSpawns large numbers of small objects, commonly used to simulate smoke, fire, rain, and snow
BloomExtracts the brightest parts of a frame, blurs them, and adds them back so glowing things bleed light around them
Pointer EventsDetects pointer movement, clicks, hovers, and more, providing coordinates and target information

Examples ​

Basic Usage ​

Set element to fire or ice to apply the effect.

鱈魚
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。(´;ω;`)
View example source
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>

Form Example ​

Until the form is filled out, the button stays frozen. (´,,•ω•,,)

View example source
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>

Red-Hot Funding ​

Client: "The product should give off a red-hot feel."

Codfish: "OK."

熱門專案水中筆電
防水 100 公尺,鍵盤專為魚鰭設計,泡在水裡也能寫程式。
NT$1,200目標 NT$1,000,000
0.12%達成率
3贊助人數
2剩餘天數
View example source
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>

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. When the content is recaptured and its outline hasn't changed (for example, only the text changed), the distance field is reused instead of recomputed.

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 已相當誇張。強度低於 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;
}

v0.90.7