Skip to content

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 (╥ω╥`)

Tech Keywords

NameDescription
Pointer EventsDetects pointer movement, clicks, hovers, and more, providing coordinates and target information
SVG MaskSVG masking that controls which areas of an element are visible, enabling complex shape cutting and masking
Canvas getImageDataReads pixel data from a specified canvas region
Canvas 2D APIBasic 2D drawing API that efficiently renders graphics too complex for the DOM

Usage Examples

Basic Usage

Like a classic scratch card, you can scratch off the surface pattern.

View example source code
vue
<template>
  <div class="w-full flex flex-col gap-4 example-wrap">
    <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 ( ´థ౪థ)

Please read the following terms carefully and 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

  1. Do not put codfish in the washing machine; it probably won't get clean anyway.
  2. Do not use codfish as a weapon, unless it has been frozen into an ice block first.
  3. 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.

Read rate: 100.00%
Must reach 100% read rate to check the box (・∀・)9
View example source code
vue
<template>
  <div class="relative w-full flex flex-center flex-col gap-4 example-wrap">
    <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 example-ctrl"
        :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;
}>()

v0.70.3