Skip to content

Sour Lemon Cursor cursor

The cursor turns into a lemon slice, and whatever it touches puckers inward. (◞✱◟ )

Inspired by the Sour Lemon Meme.

Usage Examples

Basic Usage

Mount cursor-sour-lemon to enable the lemon cursor, then mark elements to pucker with v-sour-lemon="{ ... }". Any marked element caves in when the cursor touches it.

So sour it caves in (◞✱◟ )
View example source code
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>

Lemon Stand

A one-page storefront scenario. Click the lemon in the hero to pick up the cursor, click again to put it back. Everything from the tagline down to the footer is marked — nothing on this page escapes the lemon.

  • Products: caving params are data-driven by sourness — the more sour the variety, the harder it caves (intensity, falloff)
  • Sale: the original price tag uses center to aim the dent right at the digits
  • Everything else: marked with a bare v-sour-lemon, running entirely on defaults
Codfish Lemon Farm · Fresh from the Deep SeaSo Sour! Codfish Lemon FarmTap the lemon. Everything on this page is scared of it
Fresh This Season (Low Tide Only)
Fragrant LemonSourness 🍋🍋Fake-sour specialist, preys on sour rookiesNT$60 / catty
Yellow LemonSourness 🍋🍋🍋🍋Sour enough to launch grandma's denturesNT$80 / catty
LimeSourness 🍋🍋🍋🍋🍋So sour your soul leaves your body. Exorcism includedNT$75 / catty
Limited Time
Ugly Lemon Bundle (Ugly Discount)The photographer demanded extra pay to shoot it, so you get it cheap
Was NT$120 Now NT$79
© Codfish Lemon Farm · Lemons guaranteed unable to swim
View example source code
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>

How It Works

Responsibilities split three ways: the v-sour-lemon directive only registers, the caving machinery lives in a shared controller, and the cursor-sour-lemon component owns the lemon's presentation and activation.

  • Directive: mounted registers the element and its params into the controller, updated refreshes the params, unmounted deregisters — it never touches the filter or animation itself.
  • Controller: for each registered element it injects its own SVG caving filter, generates surface maps with Canvas (a ResizeObserver recomputes them on resize), and detects touch via pointerenter/pointerleave. While active it releases the implicit pointer capture on pointerdown, so dragging a held finger across cards caves each one in turn. All elements share a single rAF spring loop that stops once settled.
  • Cursor: reads the position with VueUse's useMouse, detects presses with useMousePressed, and teleports a hand-drawn SVG to the cursor; on mount it flips the global active flag and hides the native cursor, restoring on unmount. Caving happens only while the cursor is mounted and touching a marked element. Touch devices have no hover, so useMediaQuery('(hover: none)') keeps the lemon hidden until pressed, where it appears under the finger and hides again on release.

The caving and the crease shadows come from a single SVG filter graph, with displacement and shadow each doing their own job — displacement handles only the clean radial contraction, the creases show up only in the shadow. The whole thing breaks into four steps:

  1. Canvas pre-generates two surface maps: a displacement map (the R and G channels encode the outward radial displacement) and a shadow map (grayscale light and dark, with radial creases rendered as clean dark lines). Both maps are recomputed only when the size or parameters change.
  2. feDisplacementMap samples the outer content according to the displacement map, pulling the image toward the center to form an inward-sucking dent. The same technique appears in Hi Hue.
  3. feColorMatrix turns the grayscale shadow map into a shadow layer of "pure black, alpha = caving amount × (1 − luminance)"; during animation only the matrix values change per frame, which is extremely cheap.
  4. feComposite operator="over" lays the shadow layer onto the contracted content. For opaque content this is mathematically identical to multiplying (multiplying by s ≡ overlaying pure black with alpha 1 − s), and the dark lines also show up in transparent regions, so elements without a background (such as bare text) get creases too.

The shadow uses occlusion-style darkening rather than directional light. Directional light hitting the radial ridges would turn into a half-bright, half-dark pinwheel — nothing like a crease. A crease is essentially the shadow of a groove: no matter where the light comes from, the bottom of the groove stays dark, so the groove is darkened uniformly based on crease depth — a thin crease becomes a thin dark line. The crease lines get random angles, depth, and thickness from a seed fixed per element, so they don't jump around on rescale or recompute, and they fade out at the center and the edges.

The displacement uses bounded radial contraction: the sampling radius σ(ρ) = range · t^a (t = ρ/range) always stays ≤ range, and the displacement amount is σ − ρ, leaving the boundary (t=1) untouched. So it only contracts content within range, never samples outside the element, and the outer transparent region won't be swept into the center.

The contraction exponent is a = 1 − intensity^(1/falloff): at intensity=0, a=1 (untouched); at intensity=1, a=0, where every point within range samples all the way to the boundary, and once the content is sucked dry it disappears completely (blank content contracts to transparent). range controls the contraction radius, falloff controls how quickly it approaches full contraction.

When the lemon touches an element, pointerenter reports it, then a spring animation ramps up both the feDisplacementMap scale and the shadow interpolation's caving amount, so the element puckers inward as if it tasted something sour; leave it and it springs back with a bouncy Q-feel.

Source Code

API

v-sour-lemon

Directive params for marking an element:

/** 標記元素的酸檸檬參數,沿用原包裝器的扭曲設定 */
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