酸檸檬游標 cursor
滑鼠化作檸檬片,碰到標記的元素會酸到向內凹陷。(◞✱◟ )
靈感來自 Sour Lemon Meme。
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Vue Directive | 自定義 Vue 指令,用於封裝 DOM 操作邏輯和重複行為 |
| Canvas ImageData | 以 createImageData/putImageData 逐像素寫入資料,常用於程序化生成圖樣 |
| SVG feDisplacementMap | 依位移圖的色值偏移取樣來源像素,可做出扭曲、凹陷、波動等效果 |
| SVG feColorMatrix | 以 4×5 矩陣重算每個像素的 RGBA,可調色、轉灰階或逐格插值動畫 |
| SVG feComposite | 依 Porter-Duff 或 arithmetic 模式合成兩張濾鏡圖層,可相乘疊加 |
使用範例
基本用法
掛上 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 彈回原狀。
原始碼
API
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
interface Props {
/** 檸檬片邊長,單位 px。@default 56 */
size?: number;
}