會眨眼、會揮手、會掀桌的 Logo:拆解顏文字工具站的動態 emoji Logo

先講個真心話:我這個設計師轉工程師的人,對「小細節」特別沒有抵抗力。今天想跟你分享一個我最近一直回去看的工具站——Kaomoji Garden 顏文字花園。它本身是一個複製貼上用的顏文字 / 西式表情符號(emoticon)資料庫,但真正讓我「哇」出聲的,是它左上角那顆 會動的 Logo。
它不是一張 GIF,也不是 SVG 動畫,而是一張用「文字」拼出來的顏文字臉——而且眼睛會飄、會眨、手會揮、心會跳,偶爾還會給你來一個 (╯°□°)╯︵ ┻━┻ 掀桌。每隔幾秒還會換一張臉。我盯著它看了大概兩分鐘,然後就把原始碼挖出來研究了 😂 這篇就帶你一起拆。
上面那顆會動的 Logo,是我把原站的
AnimatedLogo.vue移植進這篇文章的 live demo:你看到的眨眼、揮手、掀桌全是瀏覽器即時算出來的。接下來就用它來逐層拆解原理。想看它在原站長怎樣,隨時可以開 Kaomoji Garden。
先聊它的價值:為什麼要有這種站
顏文字這東西的痛點很實際:你想打一個無奈的 (´・_・),但根本記不起來怎麼組,輸入法也不一定有。Kaomoji Garden 把 1,700 多個顏文字 + 西式表情符號照「情緒」分類,點一下就複製,還能切換日式 ( ´▽`)跟西式:)` 兩種風格、收藏常用的。整站是 Vite + Vue 3 用 vite-ssg 預渲染出來的靜態頁,載入超快、對 SEO 也友善。
工具好用是基本盤,但「願意在 Logo 這種沒人會檢查的地方下苦工」,這件事最打動我。
拆解一:Logo 是「文字」,不是圖片
關鍵檔案在 src/components/AnimatedLogo.vue。第一個讓我意外的設計是——整顆 Logo 是用一個個 <span> 把顏文字拆開來拼的。每一張臉都被「逐字」規劃好:哪個字是會飄的眼睛、哪個是會揮的手、哪個是會閃的星星。
// 每張臉都拆成 parts;a = 動畫 class,d = 各部位的延遲,wrap = 整張臉的動畫
const FACES = [
{ parts: [{ t: '(' }, { t: '◕', a: 'a-eyeLR' }, { t: '‿' }, { t: '◕', a: 'a-eyeLRrev' }, { t: ')' }] },
{ parts: [{ t: '(' }, { t: '•', a: 'a-eyeUD' }, { t: 'ω' }, { t: '•', a: 'a-eyeUD', d: '.25s' }, { t: ')' }, { t: 'ノ', a: 'a-hand' }] },
{ parts: [{ t: '(╯°□°)' }, { t: '╯', a: 'a-flip' }, { t: '︵' }, { t: '┻━┻', a: 'a-tbl' }] },
// …總共 20 張臉
]你看第一張臉 (◕‿◕),兩顆 ◕ 分別掛了 a-eyeLR 和 a-eyeLRrev——一個往右飄、一個往左飄(reverse),眼神就「對在一起」活了起來。第二張的右眼還加了 d: '.25s' 延遲,讓兩眼不要同步,看起來更自然。這種「把一張臉當成有部位的角色去拆」的思路,完全是動畫師的腦袋。
模板端就是一個雙層 v-for,把這份資料渲染出來:
<span class="kao-text inline-flex text-brand-500" :class="face.wrap">
<span
v-for="(p, i) in face.parts"
:key="i"
class="inline-block"
:class="p.a"
:style="p.d ? { animationDelay: p.d } : null"
>{{ p.t }}</span>
</span>拆解二:動畫全靠純 CSS @keyframes
這裡沒有任何動畫函式庫,全部是 CSS @keyframes。每個部位的「個性」就是一段 keyframe。我特別喜歡這幾個,因為它們很「便宜」(只改 transform / opacity,GPU 友善)卻很有戲:
/* 眼睛左右飄:位移才 1.4px,但人眼超敏感,就活了 */
@keyframes eyeLR { 0%, 100% { transform: translateX(-1.4px); } 50% { transform: translateX(1.4px); } }
/* 眨眼:88% 的時間都睜著,只在最後一瞬把 Y 軸壓扁成 0.15 */
@keyframes blink { 0%, 88%, 100% { transform: scaleY(1); } 94% { transform: scaleY(0.15); } }
/* 揮手:邊抬高邊旋轉,再回擺一下 */
@keyframes handWave {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-2px) rotate(16deg); }
60% { transform: translateY(0.5px) rotate(-8deg); }
}
/* 掀桌:桌子被往上甩起來並翻轉 */
@keyframes tableBob { 0%, 100% { transform: translateY(0) rotate(0deg); } 40% { transform: translateY(-3px) rotate(-8deg); } }兩個「為什麼這樣做」的小心法,我覺得很值得抄走:
- 位移量都很小(1~3px)。 顏文字本身才十幾 px 大,眼睛動 1.4px 就剛好「活」而不會「脫窗」。動畫不是越大越好,是越貼合尺寸越好。
- 眨眼要設
transform-origin: center。scaleY(0.15)是把高度壓扁,如果原點在頂端,眼睛會「往下塌」很怪;設成 center 才會像真的閉眼。
而像「縮在一起發抖」(。>﹏<。) 這種整張臉一起抖的,就用 wrap 給整個外層套 a-shake,不必逐字處理。
拆解三:每 3.2 秒換一張臉的「輪播」
Logo 會換臉,靠的是一個很樸素的 setInterval:
const idx = ref(0) // SSR 階段固定,避免 hydration 不一致
const face = computed(() => FACES[idx.value])
function pickRandom() {
let n = Math.floor(Math.random() * FACES.length)
if (n === idx.value) n = (n + 1) % FACES.length // 避免抽到同一張
idx.value = n
}
onMounted(() => {
pickRandom()
timer = setInterval(pickRandom, 3200)
})
onUnmounted(() => clearInterval(timer))這裡有兩個我很欣賞的細節。第一,idx 初始固定為 0、等 onMounted(也就是在瀏覽器端)才隨機——因為這站是 SSG 預渲染的,伺服器跟瀏覽器算出同一張臉,hydration 才不會打架。第二,換臉時還特地避開「抽到自己」,不然你會看到 Logo「明明換了卻沒變」的尷尬。
新臉出場時還會套一個 Tailwind 定義的 pop-in,輕輕彈一下:
'pop-in': {
'0%': { opacity: '0', transform: 'scale(0.96) translateY(4px)' },
'100%': { opacity: '1', transform: 'scale(1) translateY(0)' },
}最後一個,也是我最尊敬的:尊重「不想看動畫」的人
整支元件最後有這段,我給它鼓掌:
@media (prefers-reduced-motion: reduce) {
.logo-float,
.logo-float * {
animation: none !important;
}
}只要使用者在系統設定裡開了「減少動態效果」(前庭功能敏感、容易頭暈的人會開),所有動畫直接全關。一顆裝飾性的 Logo 都願意做無障礙,這就是把使用者放在心上的態度。整顆 Logo 還加了 aria-hidden="true",讓螢幕報讀器直接略過這串對它沒意義的符號。
收尾
這顆 Logo 的厲害之處,不在用了什麼黑科技,而在它把「文字拆成角色部位 → 純 CSS 給每個部位一段微動畫 → 定時輪播換臉 → 還顧到 SSR 與無障礙」這條鏈路想得很完整。給我們的啟發很簡單:動畫的靈魂在觀察,不在工具。 你不需要 Lottie,幾段 translate 1.4px 的 keyframe 就能讓一張臉活起來。
想親眼看它眨眼、揮手、掀桌,去這裡:kaomoji.pocketool.app,或從工具站入口逛 /tools/kaomoji。記得把游標移到 Logo 上——它還會 wiggle 給你看 😉
我們下篇見,我是卡斯伯。