Skip to content

酸檸檬游標 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="{ ... }" 標記要凹陷的元素。游標碰到標記元素即向內凹陷。

酸到凹掉 (◞✱◟ )
查看範例原始碼
vue
<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 全都掛上指令,整頁沒有東西躲得掉。

  • 商品:凹陷參數由酸度資料驅動,越酸的品種凹得越兇(intensityfalloff
  • 優惠:原價標籤用 center 把凹陷對準價格數字
  • 其他元素:不帶參數直接掛 v-sour-lemon,全部走預設值
鱈魚檸檬園・深海直送酸欸!鱈魚檸檬園點檸檬拿起來,這頁所有東西都怕它
本季鮮採(退潮限定)
香水檸檬酸度 🍋🍋假酸高手,專騙怕酸仔NT$60 / 斤
黃檸檬酸度 🍋🍋🍋🍋酸到阿嬤的假牙噴飛NT$80 / 斤
萊姆酸度 🍋🍋🍋🍋🍋酸到靈魂出竅,附收驚服務NT$75 / 斤
期間限定
醜檸檬福袋(醜到打折)攝影師說要加錢才肯拍,乾脆算你便宜
原價 NT$120 特價 NT$79
© 鱈魚檸檬園・檸檬保證不會游泳
查看範例原始碼
vue
<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 監看尺寸變化重算),並以 pointerenterpointerleave 偵測檸檬是否碰到。啟用時於 pointerdown 解除觸控的隱式 pointer capture,按住拖過卡片才能逐張凹陷。所有元素共用一條 rAF 彈簧迴圈,收斂即停。
  • 游標:以 VueUse 的 useMouse 取得座標、useMousePressed 偵測按壓,再 teleport 手繪 SVG 至游標位置;掛載時開啟全域啟用旗標並隱藏原生游標,卸載還原。唯有游標掛載、且檸檬碰到標記元素才凹陷。觸控裝置沒有 hover,故 useMediaQuery('(hover: none)') 判定後平時隱藏檸檬,按住才出現並跟著手指,放開即隱藏。

凹陷與摺痕陰影出自同一個 SVG filter graph,位移與陰影各司其職,位移只管乾淨的徑向收縮,摺痕只在陰影現形。整體分四步:

  1. 用 Canvas 預先產生兩張表面圖:位移圖(R、G 通道編碼朝外的徑向位移)與陰影圖(灰階明暗,放射狀摺痕化作乾淨暗線)。兩張圖僅在尺寸或參數變化時重算。
  2. feDisplacementMap 依位移圖取樣外側內容,畫面朝中心聚攏,形成往中心吸陷的凹痕。同樣手法見於魚花
  3. feColorMatrix 把灰階陰影圖轉成「純黑、alpha = 凹陷量 ×(1 − 亮度)」的陰影層,動畫時逐格只改矩陣值,成本極低。
  4. feComposite operator="over" 把陰影層疊上收縮後的內容。對不透明內容與相乘完全等價(乘以 s ≡ 疊上 alpha 為 1 − s 的純黑),透明區域也會顯出暗線,因此沒有背景的元素(例如純文字)一樣有皺褶。

陰影採遮蔽式暗化而非方向光。方向光打在放射脊上會變成半亮半暗的風車,不像摺痕。摺痕本質是凹槽的陰影,不論光從哪來槽底都暗,故直接依摺痕深度將凹槽壓成均勻暗線,細窄摺痕即細窄暗線。摺痕線帶隨機角度、深淺、粗細,以元素固定的種子產生,縮放重算都不亂跳,並在中心與邊緣淡出。

位移採有界徑向收縮,取樣半徑 σ(ρ) = range · t^at = ρ/range)恆 ≤ range,位移量則為 σ − ρ,邊界處(t=1)為原樣。因此只收縮範圍內的內容、絕不取樣到元素外部,外側透明區也不致捲進中心。

收縮指數為 a = 1 − intensity^(1/falloff)intensity=0a=1(原樣),intensity=1a=0,範圍內每個點都取樣到邊界,內容收乾後完全消失(留白內容則收成透明)。range 控制收縮半徑、falloff 控制多快逼近完全收縮。

檸檬碰到元素時 pointerenter 回報,再以彈簧動畫同步拉高 feDisplacementMapscale 與陰影插值的凹陷量,讓元素酸到向內凹陷,離開則 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;
}

v0.67.0