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.
View example source code
<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
centerto aim the dent right at the digits - Everything else: marked with a bare
v-sour-lemon, running entirely on defaults
View example source code
<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:
mountedregisters the element and its params into the controller,updatedrefreshes the params,unmountedderegisters — 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
ResizeObserverrecomputes them on resize), and detects touch viapointerenter/pointerleave. While active it releases the implicit pointer capture onpointerdown, 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 withuseMousePressed, 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, souseMediaQuery('(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:
- 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.
feDisplacementMapsamples 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.feColorMatrixturns 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.feComposite operator="over"lays the shadow layer onto the contracted content. For opaque content this is mathematically identical to multiplying (multiplying bys≡ overlaying pure black with alpha1 − 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;
}