Skip to content
Welcome to vote for your favorite component! You can also tell me anything you want to say! (*´∀`)~♥

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
vue
<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 ( ´థ౪థ)

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 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;
}>()

v0.60.0