Mascot Card card
A bunch of little animals live behind the card, peeking out, running around, and jumping out to remind you they exist. (๑•̀ㅂ•́)و✧
On the Microsoft Clarity website, animals sneak a peek from behind the cards. It was way too cute, so I decided to make my own right away. (*´∀`)~♥
The models come from Kenney's Cube Pets, licensed under CC0. All 24 blocky animals ship with built-in idle, run, dance and other animation clips, ready to use as is. ◝( •ω• )◟
Thank you, Kenney! Praise be to Kenney! ੭ ˙ᗜ˙ )੭
Tech Keywords
| Name | Description |
|---|---|
| Babylon.js | 3D engine |
| glTF | Standard transmission format for 3D models; .glb is its binary bundle carrying meshes, materials, and animations |
| Depth Buffer | Stores the distance from the camera for every pixel so the renderer can tell which surface is in front |
| Anime.js | Lightweight JavaScript animation library |
| IntersectionObserver | Detects when elements enter or leave the viewport |
Usage Examples
Basic Usage
Hover over the card and an animal comes out to say hi right away. ( ´ ▽ ` )ノ
這張卡片後面住了一群小動物,有空就會出來刷存在感。
View example source code
<template>
<div class="example-wrap w-full flex flex-col gap-6">
<div class="example-ctrl flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-4">
<base-checkbox
v-model="autoplay"
:label="t('autoplay')"
/>
<base-btn
:label="t('play')"
@click="handlePlay"
/>
<span class="text-sm opacity-70">
{{ statusText }}
</span>
</div>
<select-stepper
v-model="act"
class="max-w-96 w-full"
:label="t('actTitle')"
:options="actOptionList"
:option-label-map="actLabelMap"
/>
<select-stepper
v-model="animal"
class="max-w-96 w-full"
:label="t('animalTitle')"
:options="animalOptionList"
:option-label-map="animalLabelMap"
/>
</div>
<div class="flex justify-center py-16">
<card-mascot
ref="cardRef"
:autoplay
class="mascot-card max-w-full w-80 rounded-2xl p-8 shadow-lg"
@act-start="handleActStart"
@act-end="handleActEnd"
>
<div class="flex flex-col gap-3">
<div class="text-xl font-bold">
{{ t('title') }}
</div>
<p class="leading-relaxed opacity-80">
{{ t('description') }}
</p>
</div>
</card-mascot>
</div>
</div>
</template>
<script setup lang="ts">
import type { ActName, ActPayload, AnimalName } from '../type'
import { camelCase } from 'lodash-es'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import SelectStepper from '../../select-stepper.vue'
import CardMascot from '../card-mascot.vue'
import { actNameList, animalNameList } from '../type'
type ActOption = ActName | 'random'
type AnimalOption = AnimalName | 'random'
const { t } = useI18n()
const cardRef = ref<InstanceType<typeof CardMascot>>()
const autoplay = ref(true)
const act = ref<ActOption>('random')
const animal = ref<AnimalOption>('random')
/** 正在演出的場次,結束後清空 */
const currentPayload = ref<ActPayload>()
const actOptionList: ActOption[] = ['random', ...actNameList]
const animalOptionList: AnimalOption[] = ['random', ...animalNameList]
/** 顯示「中文名稱 英文代號」,測試時對照程式碼比較方便 */
const actLabelMap = computed<Record<ActOption, string>>(() => {
const labelMap = { random: t('randomAct') } as Record<ActOption, string>
actNameList.forEach((name) => {
labelMap[name] = `${t(`act.${camelCase(name)}`)} ${name}`
})
return labelMap
})
const animalLabelMap = computed<Record<AnimalOption, string>>(() => {
const labelMap = { random: t('randomAnimal') } as Record<AnimalOption, string>
animalNameList.forEach((name) => {
labelMap[name] = `${t(`animal.${name}`)} ${name}`
})
return labelMap
})
const statusText = computed(() => {
const payload = currentPayload.value
if (!payload) {
return t('resting')
}
return t('performing', {
animal: animalLabelMap.value[payload.animal],
act: payload.act === 'custom' ? t('customAct') : actLabelMap.value[payload.act],
})
})
function handlePlay() {
cardRef.value?.play(
act.value === 'random' ? undefined : act.value,
animal.value === 'random' ? undefined : animal.value,
{ interrupt: true },
)
}
// 切換動作或動物就直接演,翻頁測試不用再按按鈕
watch([act, animal], () => handlePlay())
function handleActStart(payload: ActPayload) {
currentPayload.value = payload
}
function handleActEnd() {
currentPayload.value = undefined
}
</script>
<style lang="sass" scoped>
.mascot-card
background: light-dark(#FFF, #1e1e1e)
border: 1px solid light-dark(#e5e7eb, #3a3a3a)
color: light-dark(#374151, #d1d5db)
</style>Newsletter Signup
View example source code
<template>
<div class="example-wrap w-full flex justify-center py-16">
<card-mascot
ref="cardRef"
:autoplay="false"
:animal-list="['chick']"
:act-list="idleActList"
class="newsletter-card max-w-full w-96 rounded-2xl p-8 shadow-lg"
>
<form
ref="formRef"
class="flex flex-col gap-4"
novalidate
:style="{ minHeight: formMinHeight > 0 ? `${formMinHeight}px` : undefined }"
@submit.prevent="handleSubmit"
>
<img
src="/low/painting-codfish-bakery.webp"
:alt="t('bannerAlt')"
class="aspect-video w-full rounded-xl object-cover"
>
<div class="text-2xl font-bold">
{{ t('title') }}
</div>
<span class="my-2 leading-relaxed opacity-80">
{{ t('description') }}
</span>
<template v-if="!isSubscribed">
<input
v-model="email"
type="email"
class="newsletter-input"
:placeholder="t('placeholder')"
@focus="handleFocus"
>
<!-- 常駐佔位,只切換透明度,錯誤訊息出現時版面不會位移 -->
<span
class="min-h-5 text-sm text-red-500 transition-opacity"
:class="errorMessage ? 'opacity-100' : 'opacity-0'"
aria-live="polite"
>
{{ errorMessage }}
</span>
<base-btn
class="self-start"
:label="t('subscribe')"
@click="handleSubmit"
/>
</template>
<template v-else>
<p class="text-green-600 font-bold">
{{ t('success') }}
</p>
<base-btn
class="self-start"
:label="t('again')"
@click="handleReset"
/>
</template>
</form>
</card-mascot>
</div>
</template>
<script setup lang="ts">
import type { ActName } from '../type'
import { useElementSize } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import CardMascot from '../card-mascot.vue'
import { actNameList } from '../type'
/** 帳號 @ 網域,網域至少兩段且每段不含點 */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/
const { t } = useI18n()
/** 互動時指定播放的動作,滑鼠移入的隨機演出就排除掉,留給對應的情境 */
const interactionActList: ActName[] = ['peek-top', 'slip', 'bounce', 'dance']
const idleActList = actNameList.filter((name) => !interactionActList.includes(name))
const cardRef = ref<InstanceType<typeof CardMascot>>()
const email = ref('')
const errorMessage = ref('')
const isSubscribed = ref(false)
const formRef = ref<HTMLFormElement>()
const { height: formHeight } = useElementSize(formRef, undefined, { box: 'border-box' })
/** 記住表單曾達到的最大高度,切成致謝內容時卡片不會縮短、版面不會跳動 */
const formMinHeight = ref(0)
watch(formHeight, (height) => {
formMinHeight.value = Math.max(formMinHeight.value, height)
})
function handleFocus() {
errorMessage.value = ''
// 有人靠近輸入框,探頭看看在打什麼
cardRef.value?.play('peek-top')
}
async function handleSubmit() {
if (isSubscribed.value)
return
if (!EMAIL_PATTERN.test(email.value)) {
errorMessage.value = t('invalid')
// 格式不對,嚇得滑倒
cardRef.value?.play('slip', undefined, { interrupt: true })
return
}
isSubscribed.value = true
// 訂閱成功,跳上來慶祝
await cardRef.value?.play('bounce', undefined, { interrupt: true })
await cardRef.value?.play('dance')
}
function handleReset() {
isSubscribed.value = false
email.value = ''
errorMessage.value = ''
}
</script>
<style lang="sass" scoped>
.newsletter-card
background: light-dark(#FFF, #1e1e1e)
border: 1px solid light-dark(#e5e7eb, #3a3a3a)
color: light-dark(#374151, #d1d5db)
.newsletter-input
padding: 0.625rem 0.875rem
border: 1px solid light-dark(#CCC, #555)
border-radius: 0.75rem
background: light-dark(#FAFAFA, #2a2a2a)
outline: none
transition: border-color 0.15s
&:focus
border-color: light-dark(#60a5fa, #3b82f6)
</style>How It Works
A canvas one size larger than the card
A transparent canvas is laid inside the card, extending a margin beyond it on every side, and Babylon.js renders the animals onto it. The canvas sits between the card background and the content, so an animal only shows up once it leaves the card area.
An invisible occluder
The scene holds a plane shaped exactly like the card, its corners rounded to match the border-radius, with a material that writes depth but no color. The animals render in a later rendering group, and everything behind the plane is discarded, so anything inside the card area disappears while the card background can stay transparent or rounded. Appearing and vanishing also fade over two hundred milliseconds by changing the opacity of the whole canvas, so the entire body fades as one.
Pixels are coordinates
The camera distance is derived from the canvas height so that one unit on the z = 0 plane equals one pixel, which puts the card edges at half the card width and height. The animal hides slightly behind that plane, so its position and scale are multiplied by a perspective compensation factor and every peek lands exactly where intended. Wide, long animals retreat further back. size is only an upper bound, and the animal shrinks when the card is too small.
Acts stitched from tweens
Every act is an async function that tweens the animal's pose with anime.js. Peeking from a corner, for example, is three steps: lean out sideways, look around, slide back. The pose is a { x, y, yaw, pitch, roll, scaleX, scaleY } object, converted to scene coordinates right before each frame renders. play() also accepts a custom act function. When the card scrolls out of view, the component unmounts, or an act is interrupted, every tween stops together and the animal fades out in its current pose.
Blending between clips
The models only ship with a handful of animation clips such as idle, walk, run, and dance. Every AnimationGroup has enableBlending turned on, so a new clip blends in from the current pose and switching clips leaves no visible seam.
Source Code
API
Props
interface Props {
/** 會登場的動物,預設 24 種輪番上陣 */
animalList?: AnimalName[];
/** 會演出的動作,預設全部 */
actList?: ActName[];
/** 動物身高上限(px)。卡片太小時會依卡片寬高自動縮小,確保躲得進卡片後面。@default 80 */
size?: number;
/** 兩場演出之間的休息時間範圍(ms)。@default [1500, 4000] */
intervalRange?: [number, number];
/** 掛載後自動輪番演出。@default true */
autoplay?: boolean;
/** 滑鼠移入卡片時,若動物正在休息就立刻上場。@default true */
shouldPlayOnHover?: boolean;
/** 模型檔案所在目錄。@default '/kenney-cube-pets/' */
modelBaseUrl?: string;
}Emits
interface Emits {
/** 一場演出開始 */
actStart: [payload: ActPayload];
/** 一場演出結束,中途取消也算 */
actEnd: [payload: ActPayload];
}Methods
interface Expose {
/**
* 立刻演一場,演完才 resolve。未指定動作或動物就隨機挑,
* 也可以直接給一段自訂動作函式;有演出進行中時預設略過,interrupt 可強制換場
*/
play: (act?: ActName | ActFn, animal?: AnimalName, options?: PlayOptions) => Promise<void>;
/** 暫停自動輪播,進行中的演出會演完 */
pause: () => void;
/** 恢復自動輪播 */
resume: () => void;
}Slots
interface Slots {
default?: (data: {
/** 是否有動物正在演出 */
isActing: boolean;
/** 目前演出的動作,自訂動作為 custom */
act?: ActPayload['act'];
}) => unknown;
}