Scratch Off Wrapper wrapper
Turn any content into a scratch card in one second ヽ(́◕◞౪◟◕‵)ノ
iOS Safari Not Supported
Currently (2025/12/10) tested, iOS Safari's CSS + SVG Mask has issues and cannot properly display the scratch-off effect. No solution has been found yet (╥ω╥`)
Usage Examples
Basic Usage
Like a classic scratch card, you can scratch off the surface pattern.
View example source code
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<wrapper-scratch-off
ref="scratchOffRef"
class="h-[40vh] w-full"
@update:remain-ratio="(value) => handleUpdateRemainRatio(value)"
>
<div
class="relative h-full w-full flex flex-col select-none items-center justify-center gap-4 border p-4"
:class="{ ' pointer-events-none': remainRatioNumber > 85 }"
>
<a
href="https://codlin.me/article-overview.html"
target="_blank"
class="text-xl font-bold duration-300"
:class="{ ' opacity-20': remainRatioNumber > 85 }"
>
{{ t('congratulations') }}
</a>
<div
class="text-xs opacity-70 duration-300"
:class="{ ' !opacity-0': remainRatioNumber <= 85 }"
>
{{ t('scratchMore') }}
</div>
<a
href="https://165.npa.gov.tw/"
target="_blank"
class="absolute bottom-0 right-0 p-2 text-xs opacity-0 duration-500"
:class="{ '!opacity-100': remainRatioNumber <= 70 }"
>
{{ t('justKidding') }}
</a>
</div>
</wrapper-scratch-off>
<div class="text-center text-2xl font-bold">
{{ remainRatio }}%
</div>
<base-btn
:label="t('reset')"
@click="handleReset"
/>
</div>
</template>
<script setup lang="ts">
import { animate } from 'animejs-v4'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import WrapperScratchOff from '../wrapper-scratch-off.vue'
const { t } = useI18n()
const remainRatio = ref('100.00')
const remainRatioNumber = computed(() => Number.parseFloat(remainRatio.value))
function handleUpdateRemainRatio(value: number) {
animate(remainRatio, {
value: value * 100,
duration: 300,
modifier: (value) => value.toFixed(2),
})
}
const scratchOffRef = useTemplateRef('scratchOffRef')
function handleReset() {
scratchOffRef.value?.reset()
}
</script>Terms of Use
Not only do you have to read carefully, you also have to scratch off the mask ( ´థ౪థ)
🐟 Codfish User Guide
📌 Important Disclaimer
This guide applies to any codfish-related activities, such as eating, viewing, chatting, or attempting to establish a deep friendship with one (not recommended).
🍽️ Dining Guidelines
- Please ensure the codfish is fully cooked, unless you are a polar bear.
- If you find the codfish smiling at you from the plate, please confirm you are not on drugs.
- Codfish contains unnameable substances; eating too much may cause hallucinations.
🐠 Viewing Guidelines
- Codfish have a modest appearance; please refrain from discrimination.
- Please do not say "you're so fat" to codfish at the aquarium; they have self-esteem too.
💬 Communication Guidelines
- Codfish cannot speak, so please don't give them lengthy speeches.
- If a codfish nods at you, don't get too excited—it might just be the water current.
- When attempting telepathic communication with codfish, make sure you're not hallucinating from hunger.
🚨 Prohibited Actions
- Do not put codfish in the washing machine; it probably won't get clean anyway.
- Do not use codfish as a weapon, unless it has been frozen into an ice block first.
- Do not walk the codfish, because it cannot walk.
🎉 Conclusion
Please treat codfish with respect and humor. If you have any complaints, remember it's just a fish.
Feel free to submit an MR to supplement these guidelines.
View example source code
<template>
<div class="relative w-full flex flex-center flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="w-full text-center text-lg font-bold">
{{ t('readCarefully') }}
</div>
<div class="h-[55vh] overflow-y-auto">
<wrapper-scratch-off
ref="wrapperScratchOffRef"
class="select-none"
:stroke-width="100"
trigger-scratch-off-mode="hover"
@update:remain-ratio="(value) => handleUpdateRemainRatio(value)"
>
<h1>{{ t('termsTitle') }}</h1>
<h2>
{{ t('importantDisclaimer') }}
</h2>
<p>{{ t('disclaimerContent') }}</p>
<h2>
{{ t('diningTitle') }}
</h2>
<ul>
<li>{{ t('dining1') }}</li>
<li>{{ t('dining2') }}</li>
<li>{{ t('dining3') }}</li>
</ul>
<h2>
{{ t('viewingTitle') }}
</h2>
<ul>
<li>{{ t('viewing1') }}</li>
<li>{{ t('viewing2') }}</li>
</ul>
<h2>
{{ t('communicationTitle') }}
</h2>
<ul>
<li>{{ t('communication1') }}</li>
<li>{{ t('communication2') }}</li>
<li>{{ t('communication3') }}</li>
</ul>
<h2>
{{ t('prohibitedTitle') }}
</h2>
<ol>
<li>{{ t('prohibited1') }}</li>
<li>{{ t('prohibited2') }}</li>
<li>{{ t('prohibited3') }}</li>
</ol>
<h2>
{{ t('conclusionTitle') }}
</h2>
<p>{{ t('conclusion1') }}</p>
<p>{{ t('conclusion2') }}</p>
<template #mask>
<div class="mask h-full w-full" />
</template>
</wrapper-scratch-off>
</div>
<div class="text-center text-lg font-bold">
{{ t('readRate') }}{{ remainRatio }}%
</div>
<div class="text-center text-xs">
{{ t('readRateHint') }}
</div>
<div
class="w-full duration-500"
:class="{ 'cursor-not-allowed opacity-80': !isOk }"
>
<base-checkbox
v-model="value"
:label="t('checkboxLabel')"
class="w-full border rounded-xl p-4"
:class="{ ' pointer-events-none': !isOk }"
/>
</div>
<transition name="opacity">
<div
v-if="value"
class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-[#383c3d] bg-opacity-90"
>
<span class="text-xl text-white tracking-wide">
{{ t('thankYou') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { refAutoReset, whenever } from '@vueuse/core'
import { animate } from 'animejs-v4'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import WrapperScratchOff from '../wrapper-scratch-off.vue'
const { t } = useI18n()
const wrapperScratchOffRef = useTemplateRef('wrapperScratchOffRef')
const remainRatio = ref('100.00')
const isOk = computed(() => Number.parseFloat(remainRatio.value) >= 100)
function handleUpdateRemainRatio(value: number) {
animate(remainRatio, {
value: (1 - value) * 100,
duration: 300,
modifier: (value) => value.toFixed(2),
})
}
const value = refAutoReset(false, 2000)
whenever(() => !value.value, () => {
wrapperScratchOffRef.value?.reset()
})
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
.mask
background: url('/low/profile-2.webp') center center
background-size: 10%
</style>How It Works
To achieve a custom mask, SVG Mask is used to implement the scratch-off effect, combined with Canvas to calculate the scratch percentage.
Source Code
API
Props
interface Props {
disabled?: boolean;
/** 刮除比例。用於 v-model 綁定 */
remainRatio?: number;
/** 刮痕寬度 */
strokeWidth?: number;
/** 觸發刮除方式,hover 或按著不放 */
triggerScratchOffMode?: 'hover' | 'hold';
}Emits
const emit = defineEmits<{
'update:remainRatio': [value: number];
}>()Methods
defineExpose({
reset,
/** 刮除比例 */
remainRatio,
})Slots
defineSlots<{
mask?: (params: { remainRatio: number }) => unknown;
default?: (params: { remainRatio: number }) => unknown;
}>()