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>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. 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.
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.feColorMatrixinterpolates between "white (no effect) ↔ full shadow" based on the current caving amount (out = depth·shading + (1 − depth)); during animation only the matrix values change per frame, which is extremely cheap.feComposite operator="arithmetic"(k1=1) multiplies the shadow onto the contracted content, so the crease dark lines fall onto the caved surface.
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;
}