每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
酸檸檬游標
cursor滑鼠化作檸檬片,碰到標記的元素會酸到向內凹陷。(◞✱◟ )
靈感來自 Sour Lemon Meme。
| 名稱 | 描述 |
|---|---|
| Vue Directive | 自定義 Vue 指令,用於封裝 DOM 操作邏輯和重複行為 |
| Canvas ImageData | 以 createImageData/putImageData 逐像素寫入資料,常用於程序化生成圖樣 |
| SVG feDisplacementMap | 依位移圖的色值偏移取樣來源像素,可做出扭曲、凹陷、波動等效果 |
| SVG feColorMatrix | 以 4×5 矩陣重算每個像素的 RGBA,可調色、轉灰階或逐格插值動畫 |
| SVG feComposite | 依 Porter-Duff 或 arithmetic 模式合成兩張濾鏡圖層,可相乘疊加 |
| 彈簧阻尼系統 | 以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
掛上 cursor-sour-lemon 啟用檸檬游標,再用 v-sour-lemon="{ ... }" 標記要凹陷的元素。游標碰到標記元素即向內凹陷。
<template> <div class="flex flex-col items-center gap-8 py-10"> <!-- 拿起檸檬才掛上游標、啟用凹陷 --> <base-checkbox v-model="lemonPicked" :label="t('pickLemon')" class="example-ctrl" />
<!-- 優先 2×2,窄到擠不下才落為單欄垂直排列 --> <div class="grid grid-cols-1 w-fit justify-items-center gap-10 sm:grid-cols-2"> <div v-sour-lemon="paramMap.face" class="card card--face" > <svg viewBox="0 0 100 100" width="120" height="120" aria-hidden="true" > <circle cx="50" cy="50" r="46" fill="#FFE25A" stroke="#E0A800" stroke-width="3" /> <circle cx="27" cy="60" r="6.5" fill="#FF8E66" opacity="0.45" /> <circle cx="73" cy="60" r="6.5" fill="#FF8E66" opacity="0.45" /> <circle cx="36" cy="44" r="5" fill="#4A3A12" /> <circle cx="64" cy="44" r="5" fill="#4A3A12" /> <path d="M36 62 Q50 74 64 62" fill="none" stroke="#4A3A12" stroke-width="4" stroke-linecap="round" /> </svg> </div>
<div v-sour-lemon="paramMap.text" class="card card--text" > <span class="max-w-[8rem] text-center text-balance text-xl text-slate-700 font-black leading-snug"> {{ t('anything') }} </span> </div>
<div v-sour-lemon="paramMap.dots" class="card card--dots" > <svg viewBox="0 0 100 100" width="120" height="120" aria-hidden="true" > <circle v-for="dot in dotList" :key="`${dot.x}-${dot.y}`" :cx="dot.x" :cy="dot.y" r="3.5" fill="#F59E0B" /> </svg> </div>
<div v-sour-lemon="paramMap.heart" class="card card--heart" > <svg viewBox="0 0 100 100" width="120" height="120" aria-hidden="true" > <path d="M50 86 C 50 86 14 60 14 34 C 14 22 24 16 34 18 C 42 20 48 28 50 34 C 52 28 58 20 66 18 C 76 16 86 22 86 34 C 86 60 50 86 50 86 Z" fill="#EF4444" /> </svg> </div> </div>
<cursor-sour-lemon v-if="lemonPicked" /> </div></template>
<script setup lang="ts">import type { SourLemonParams } from '../v-sour-lemon'import { computed, ref } from 'vue'import { useI18n } from 'vue-i18n'import BaseCheckbox from '../../base-checkbox.vue'import CursorSourLemon from '../cursor-sour-lemon.vue'import { vSourLemon } from '../v-sour-lemon'
const { t } = useI18n()
const lemonPicked = ref(false)
const paramMap: Record<string, SourLemonParams> = { face: { intensity: 1, range: 0.35, center: [0.5, 0.46] }, text: { intensity: 1, range: 0.6, center: [0.5, 0.46] }, dots: { intensity: 1, range: 0.5, center: [0.5, 0.46] }, heart: { intensity: 1, range: 0.6, center: [0.5, 0.46] },}
const GRID_SIZE = 7
/** 7×7 點陣,吸縮時整片捲向中心 */const dotList = computed(() => Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => ({ x: 10 + (index % GRID_SIZE) * 13.5, y: 10 + Math.floor(index / GRID_SIZE) * 13.5, })),)</script>
<style scoped lang="sass">.card display: flex align-items: center justify-content: center width: 11rem height: 11rem border-radius: 1.5rem box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15)
&--face background: #FFF7C2
&--text background: #FDE68A
&--dots background: #1E293B
&--heart background: #FFE4E6</style>
<i18n lang="json">{ "zh-hant": { "anything": "酸到凹掉 (◞✱◟ )", "pickLemon": "拿起檸檬 🍋" }, "en": { "anything": "So sour it caves in (◞✱◟ )", "pickLemon": "Pick up the lemon 🍋" }}</i18n>一頁式商務網站情境,點 Hero 的檸檬拿起游標,再點一次放回去。從標語、標題到 footer 全都掛上指令,整頁沒有東西躲得掉。
- 商品:凹陷參數由酸度資料驅動,越酸的品種凹得越兇(
intensity、falloff) - 優惠:原價標籤用
center把凹陷對準價格數字 - 其他元素:不帶參數直接掛
v-sour-lemon,全部走預設值
<template> <div class="shop flex flex-col"> <!-- Hero:點檸檬拿起來,再點占位圈放回去 --> <section class="hero flex flex-col items-center gap-4 px-6 pb-12 pt-14 text-center"> <span v-sour-lemon class="tagline" > {{ t('tagline') }} </span>
<span v-sour-lemon="paramMap.heroTitle" class="hero-title" > {{ t('title') }} </span>
<button type="button" class="lemon-button" :aria-label="lemonPicked ? t('putBack') : t('pickHint')" @click="lemonPicked = !lemonPicked" > <span v-if="lemonPicked" v-sour-lemon class="lemon-placeholder" > {{ t('putBack') }} </span> <svg v-else viewBox="0 0 100 100" width="92" height="92" aria-hidden="true" > <ellipse cx="50" cy="58" rx="34" ry="25" fill="#FFD93B" stroke="#D9A800" stroke-width="2.5" transform="rotate(-16 50 58)" /> <ellipse cx="20" cy="68" rx="6" ry="4.5" fill="#FFD93B" stroke="#D9A800" stroke-width="2.5" transform="rotate(-16 20 68)" /> <ellipse cx="80" cy="48" rx="6" ry="4.5" fill="#FFD93B" stroke="#D9A800" stroke-width="2.5" transform="rotate(-16 80 48)" /> <path d="M76 38 Q86 22 98 26 Q92 40 78 42 Z" fill="#7CB342" stroke="#558B2F" stroke-width="2" stroke-linejoin="round" /> <ellipse cx="38" cy="46" rx="10" ry="5" fill="#FFFFFF" opacity="0.45" transform="rotate(-20 38 46)" /> </svg> </button>
<span v-sour-lemon class="hint" > {{ lemonPicked ? t('pokeHint') : t('pickHint') }} </span> </section>
<!-- 商品:酸度資料驅動凹陷參數,越酸凹越兇 --> <section class="flex flex-col items-center gap-6 px-8 py-10"> <span v-sour-lemon class="section-title" > {{ t('productsTitle') }} </span>
<div class="grid grid-cols-1 w-full gap-5 sm:grid-cols-3"> <div v-for="product in productList" :key="product.key" v-sour-lemon="getSournessParams(product.sourness)" class="product-card flex flex-col items-center gap-2 p-5 text-center" > <span class="product-figure flex items-center justify-center"> <svg viewBox="0 0 100 70" width="88" height="62" aria-hidden="true" > <ellipse cx="50" cy="38" :rx="product.radiusX" ry="22" :fill="product.bodyColor" :stroke="product.edgeColor" stroke-width="2.5" transform="rotate(-14 50 38)" /> <ellipse cx="40" cy="28" rx="9" ry="4.5" fill="#FFFFFF" opacity="0.4" transform="rotate(-18 40 28)" /> </svg> </span> <span class="product-name">{{ t(`products.${product.key}.name`) }}</span> <span class="product-sourness">{{ t('sournessLabel') }} {{ '🍋'.repeat(product.sourness) }}</span> <span class="product-desc">{{ t(`products.${product.key}.desc`) }}</span> <span class="product-price">NT${{ product.price }} <span class="product-unit">/ {{ t('unit') }}</span></span> </div> </div> </section>
<!-- 限時優惠:原價掛指令且 center 對準數字,特價不掛 --> <section class="flex flex-col items-center gap-6 px-8 pb-12 pt-4"> <span v-sour-lemon class="section-title" > {{ t('promoTitle') }} </span>
<div class="promo-banner flex flex-col items-center gap-4 px-8 py-7 text-center sm:flex-row sm:justify-between sm:text-left"> <div class="flex flex-col gap-1"> <span v-sour-lemon class="promo-name" > {{ t('promoName') }} </span> <span v-sour-lemon class="promo-note" > {{ t('promoNote') }} </span> </div> <div class="flex items-center gap-3"> <span v-sour-lemon="paramMap.originalPrice" class="price-original" > {{ t('originalPrice') }} NT$120 </span> <span v-sour-lemon class="price-sale" > {{ t('salePrice') }} NT$79 </span> </div> </div> </section>
<footer class="shop-footer flex justify-center px-6 py-5"> <span v-sour-lemon class="footer-text" > {{ t('footer') }} </span> </footer>
<cursor-sour-lemon v-if="lemonPicked" /> </div></template>
<script setup lang="ts">import type { SourLemonParams } from '../v-sour-lemon'import { ref } from 'vue'import { useI18n } from 'vue-i18n'import CursorSourLemon from '../cursor-sour-lemon.vue'import { vSourLemon } from '../v-sour-lemon'
const { t } = useI18n()
const lemonPicked = ref(false)
const paramMap: Record<string, SourLemonParams> = { heroTitle: { intensity: 0.9, range: 0.9 }, originalPrice: { intensity: 1, range: 0.5, center: [0.68, 0.5] },}
/** 酸度 1~5 轉換凹陷參數,越酸收縮越強、塌得越快 */function getSournessParams(sourness: number): SourLemonParams { return { intensity: 0.55 + sourness * 0.09, falloff: sourness * 6, range: 0.5, center: [0.5, 0.6], }}
interface Product { key: string; sourness: number; price: number; radiusX: number; bodyColor: string; edgeColor: string;}
const productList: Product[] = [ { key: 'fragrant', sourness: 2, price: 60, radiusX: 38, bodyColor: '#C9E265', edgeColor: '#8AA63A' }, { key: 'yellow', sourness: 4, price: 80, radiusX: 32, bodyColor: '#FFD93B', edgeColor: '#D9A800' }, { key: 'lime', sourness: 5, price: 75, radiusX: 28, bodyColor: '#9CCC65', edgeColor: '#558B2F' },]</script>
<style scoped lang="sass">.shop height: min(34rem, 70vh) overflow-y: auto border: 1px solid #EFE3C2 border-radius: 1.5rem background: #FFFBF0 color: #3E3322
&::-webkit-scrollbar width: 8px
&::-webkit-scrollbar-thumb border-radius: 4px background: #E8DAAE
.hero background: radial-gradient(120% 90% at 50% 0%, #FFF0B3 0%, #FFFBF0 75%)
.tagline display: flex align-items: center gap: 0.75rem font-size: 0.78rem letter-spacing: 0.35em text-indent: 0.35em color: #A98A2F
&::before, &::after content: '' width: 2rem height: 1px background: #D9C27A
.hero-title padding: 0.25rem 0.75rem font-size: 2.2rem font-weight: 900 letter-spacing: 0.04em line-height: 1.3
.lemon-button display: flex align-items: center justify-content: center width: 7rem height: 7rem border-radius: 50% background: rgba(255, 255, 255, 0.65) box-shadow: 0 10px 26px rgba(170, 130, 20, 0.18) transition: transform 0.2s ease, box-shadow 0.2s ease
&:hover transform: translateY(-3px) scale(1.05) rotate(-4deg) box-shadow: 0 14px 32px rgba(170, 130, 20, 0.24)
.lemon-placeholder display: flex align-items: center justify-content: center width: 6rem height: 6rem border: 2px dashed #CBA94A border-radius: 50% padding: 0.5rem font-size: 0.78rem line-height: 1.5 color: #A98A2F
.hint font-size: 0.85rem color: #A98A2F
.section-title position: relative padding-bottom: 0.6rem font-size: 1.3rem font-weight: 900 letter-spacing: 0.12em text-indent: 0.12em
&::after content: '' position: absolute bottom: 0 left: 50% width: 2.25rem height: 3px border-radius: 2px background: #E3B341 transform: translateX(-50%)
.product-card border: 1px solid #F1E6C8 border-radius: 1.25rem background: #FFFFFF box-shadow: 0 6px 18px rgba(150, 110, 10, 0.08) transition: transform 0.2s ease, box-shadow 0.2s ease
&:hover transform: translateY(-3px) box-shadow: 0 12px 26px rgba(150, 110, 10, 0.14)
.product-figure width: 6rem height: 6rem border-radius: 50% background: #FFF6D8
.product-name font-size: 1.05rem font-weight: 700
.product-sourness font-size: 0.78rem color: #8A6D1F
.product-desc font-size: 0.8rem line-height: 1.6 color: #A98A2F
.product-price font-size: 1.1rem font-weight: 900 color: #B45309
.product-unit font-size: 0.78rem font-weight: 400 color: #A98A2F
.promo-banner width: 100% border: 1px solid rgba(227, 179, 65, 0.45) border-radius: 1.25rem background: linear-gradient(135deg, #45390F 0%, #5C4C1A 100%) color: #FFF4CE
.promo-name font-size: 1.2rem font-weight: 900 letter-spacing: 0.04em
.promo-note font-size: 0.82rem color: #D9C27A
.price-original padding: 0.3rem 0.7rem font-size: 0.95rem text-decoration: line-through color: #CBB677
.price-sale padding: 0.45rem 1rem border-radius: 0.75rem background: #FFD93B box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) font-size: 1.3rem font-weight: 900 color: #45390F
.shop-footer border-top: 1px solid #F1E6C8
.footer-text font-size: 0.75rem letter-spacing: 0.08em color: #BCA45E</style>
<i18n lang="json">{ "zh-hant": { "tagline": "鱈魚檸檬園・深海直送", "title": "酸欸!鱈魚檸檬園", "pickHint": "點檸檬拿起來,這頁所有東西都怕它", "pokeHint": "盡量戳,整頁沒有東西躲得掉 ( ́◉◞౪◟◉)", "putBack": "檸檬去哪惹?(´;ω;`)", "productsTitle": "本季鮮採(退潮限定)", "sournessLabel": "酸度", "unit": "斤", "products": { "fragrant": { "name": "香水檸檬", "desc": "假酸高手,專騙怕酸仔" }, "yellow": { "name": "黃檸檬", "desc": "酸到阿嬤的假牙噴飛" }, "lime": { "name": "萊姆", "desc": "酸到靈魂出竅,附收驚服務" } }, "promoTitle": "期間限定", "promoName": "醜檸檬福袋(醜到打折)", "originalPrice": "原價", "salePrice": "特價", "promoNote": "攝影師說要加錢才肯拍,乾脆算你便宜", "footer": "© 鱈魚檸檬園・檸檬保證不會游泳" }, "en": { "tagline": "Codfish Lemon Farm · Fresh from the Deep Sea", "title": "So Sour! Codfish Lemon Farm", "pickHint": "Tap the lemon. Everything on this page is scared of it", "pokeHint": "Poke away. Nothing on this page can escape ( ́◉◞౪◟◉)", "putBack": "Put me back (´;ω;`)", "productsTitle": "Fresh This Season (Low Tide Only)", "sournessLabel": "Sourness", "unit": "catty", "products": { "fragrant": { "name": "Fragrant Lemon", "desc": "Fake-sour specialist, preys on sour rookies" }, "yellow": { "name": "Yellow Lemon", "desc": "Sour enough to launch grandma's dentures" }, "lime": { "name": "Lime", "desc": "So sour your soul leaves your body. Exorcism included" } }, "promoTitle": "Limited Time", "promoName": "Ugly Lemon Bundle (Ugly Discount)", "originalPrice": "Was", "salePrice": "Now", "promoNote": "The photographer demanded extra pay to shoot it, so you get it cheap", "footer": "© Codfish Lemon Farm · Lemons guaranteed unable to swim" }}</i18n>職責切成三塊,v-sour-lemon 指令只做登記,凹陷機構集中在共享 controller,cursor-sour-lemon 元件掌控檸檬呈現與啟用。
- 指令:
mounted登記元素與參數至 controller、updated更新參數、unmounted註銷,本身不碰濾鏡與動畫。 - controller:為每個登記元素各自注入一組 SVG 凹陷濾鏡,以 Canvas 生成表面圖(
ResizeObserver監看尺寸變化重算),並以pointerenter/pointerleave偵測檸檬是否碰到。啟用時於pointerdown解除觸控的隱式 pointer capture,按住拖過卡片才能逐張凹陷。所有元素共用一條 rAF 彈簧迴圈,收斂即停。 - 游標:以 VueUse 的
useMouse取得座標、useMousePressed偵測按壓,再 teleport 手繪 SVG 至游標位置;掛載時開啟全域啟用旗標並隱藏原生游標,卸載還原。唯有游標掛載、且檸檬碰到標記元素才凹陷。觸控裝置沒有 hover,故useMediaQuery('(hover: none)')判定後平時隱藏檸檬,按住才出現並跟著手指,放開即隱藏。
凹陷與摺痕陰影出自同一個 SVG filter graph,位移與陰影各司其職,位移只管乾淨的徑向收縮,摺痕只在陰影現形。整體分四步:
- 用 Canvas 預先產生兩張表面圖:位移圖(R、G 通道編碼朝外的徑向位移)與陰影圖(灰階明暗,放射狀摺痕化作乾淨暗線)。兩張圖僅在尺寸或參數變化時重算。
feDisplacementMap依位移圖取樣外側內容,畫面朝中心聚攏,形成往中心吸陷的凹痕。同樣手法見於魚花。feColorMatrix把灰階陰影圖轉成「純黑、alpha = 凹陷量 ×(1 − 亮度)」的陰影層,動畫時逐格只改矩陣值,成本極低。feComposite operator="over"把陰影層疊上收縮後的內容。對不透明內容與相乘完全等價(乘以s≡ 疊上 alpha 為1 − s的純黑),透明區域也會顯出暗線,因此沒有背景的元素(例如純文字)一樣有皺褶。
陰影採遮蔽式暗化而非方向光。方向光打在放射脊上會變成半亮半暗的風車,不像摺痕。摺痕本質是凹槽的陰影,不論光從哪來槽底都暗,故直接依摺痕深度將凹槽壓成均勻暗線,細窄摺痕即細窄暗線。摺痕線帶隨機角度、深淺、粗細,以元素固定的種子產生,縮放重算都不亂跳,並在中心與邊緣淡出。
位移採有界徑向收縮,取樣半徑 σ(ρ) = range · t^a(t = ρ/range)恆 ≤ range,位移量則為 σ − ρ,邊界處(t=1)為原樣。因此只收縮範圍內的內容、絕不取樣到元素外部,外側透明區也不致捲進中心。
收縮指數為 a = 1 − intensity^(1/falloff),intensity=0 時 a=1(原樣),intensity=1 時 a=0,範圍內每個點都取樣到邊界,內容收乾後完全消失(留白內容則收成透明)。range 控制收縮半徑、falloff 控制多快逼近完全收縮。
檸檬碰到元素時 pointerenter 回報,再以彈簧動畫同步拉高 feDisplacementMap 的 scale 與陰影插值的凹陷量,讓元素酸到向內凹陷,離開則 Q 彈回原狀。
v-sour-lemon
Section titled “v-sour-lemon”標記元素的指令參數:
/** 標記元素的酸檸檬參數,沿用原包裝器的扭曲設定 */export interface SourLemonParams { /** 收縮強度,0~1,1 可讓範圍內容完全收縮消失;收縮限制在範圍內、不吸入外部。@default 0.85 */ intensity?: number; /** 扭曲範圍,0~1 相對元素半徑,越小越集中於中心。@default 0.8 */ range?: number; /** 收縮曲線,越大 intensity 越快逼近完全收縮。@default 20 */ falloff?: number; /** 扭曲中心 [x, y],0~1 相對元素寬高,[0.5, 0.5] 為正中間。@default [0.5, 0.5] */ center?: [number, number];}cursor-sour-lemon Props
Section titled “cursor-sour-lemon Props”interface Props { /** 檸檬片邊長,單位 px。@default 56 */ size?: number;}