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

Jigsaw Puzzle Button button

A button that can only be clicked after you finish the jigsaw puzzle ◝( ゚∀゚ )◟

Usage Examples

Basic Usage

Drag the pieces and complete the puzzle!

View example source code
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
    <div class="flex justify-center">
      <btn-jigsaw-puzzle
        :label="t('clickMe')"
        @click="handleClick"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import BtnJigsawPuzzle from '../btn-jigsaw-puzzle.vue'

const { t } = useI18n()

function handleClick() {
  // eslint-disable-next-line no-alert
  alert(t('congratulations'))
}
</script>

Form Example

Block bots and annoying complaints! (・∀・)9 (eh?)

Complaint Feedback Form
If you have any concerns, please fill in your feedback here, we will handle your problem as soon as possible.
To prevent abuse, the jigsaw puzzle must be completed before submitting (*´∀`)~♥
View example source code
vue
<template>
  <div class="relative w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
    <div class="flex flex-col gap-3">
      <div class="text-3xl font-bold">
        {{ t('complaintFeedbackForm') }}
      </div>
      <div class="text-sm text-gray-500">
        {{ t('complaintFeedbackFormTip') }}
      </div>

      <div class="flex justify-center border border-gray-200 rounded-lg p-3">
        <textarea
          v-model="text"
          :placeholder="t('inputPlaceholder')"
          class="min-h-[30vh] w-full"
        />
      </div>

      <div class="w-full flex justify-center">
        <btn-jigsaw-puzzle
          ref="jigsawPuzzleRef"
          :row-count="3"
          :col-count="4"
          @click="handleSubmit"
        >
          <button class="btn w-full select-none rounded p-3 px-20 text-3xl">
            {{ t('submitBtn') }}
          </button>
        </btn-jigsaw-puzzle>
      </div>

      <div class="mt-2 text-center text-xs text-gray-500">
        {{ t('jigsawPuzzleTip') }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnJigsawPuzzle from '../btn-jigsaw-puzzle.vue'

const { t } = useI18n()

const jigsawPuzzleRef = useTemplateRef('jigsawPuzzleRef')

const text = ref('')
function handleSubmit() {
  // eslint-disable-next-line no-alert
  alert(t('thanks'))

  text.value = ''
  jigsawPuzzleRef.value?.scatter()
}
</script>

<style scoped lang="sass">
.btn
  background-color: light-dark(#444, #EEE)
  color: light-dark(#EEE, #444)
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

Anti-Scalping

Block those pesky scalper bots! (⌐■_■)✧

Select "Codfish Asia Concert" tickets:
Time: 2030/02/30 03:00
Location: Taiwan Strait - 24°19'22.3"N 119°58'43.3"E
To prevent abuse, complete the puzzle within 4 seconds before submitting (*´∀`)~♥

Passerby: "This even blocks real people! ლ(´口`ლ)"

View example source code
vue
<template>
  <div class="relative w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
    <div class="flex flex-col gap-3">
      <div class="max-w-xl w-full flex flex-col gap-3">
        <div class="mb-1 text-lg font-semibold">
          {{ t('selectTicket') }}
        </div>

        <div class="text-sm text-gray-500">
          {{ t('eventTime') }}<br>
          {{ t('eventLocation') }}
        </div>

        <div class="grid grid-cols-1 gap-3">
          <label
            v-for="ticket in ticketOptionList"
            :key="ticket.id"
            class="cursor-pointer"
          >
            <input
              v-model="selectedTicketId"
              type="radio"
              class="peer hidden"
              :value="ticket.id"
            >

            <div
              class="w-full flex flex-col gap-1 border rounded-lg px-4 py-3 text-sm transition-all hover:border-blue-400 peer-checked:border-blue-500 peer-checked:bg-blue-50"
            >
              <div class="flex items-center justify-between">
                <div class="text-lg text-gray-500 font-black">
                  {{ ticket.area }}
                </div>
                <div class="text-base font-bold">
                  NT$ {{ ticket.price.toLocaleString() }}
                </div>
              </div>

              <div
                v-if="ticket.note"
                class="text-xs text-amber-600"
              >
                {{ ticket.note }}
              </div>
            </div>
          </label>
        </div>
      </div>

      <div class="py-2 text-center text-gray-500">
        {{ t('timerTip', { seconds: MAX_TIMER_COUNT }) }}
      </div>

      <div
        class="w-full flex justify-center duration-300"
        :class="{ 'opacity-50 pointer-events-none': !selectedTicketId }"
      >
        <btn-jigsaw-puzzle
          ref="jigsawPuzzleRef"
          @drag-start="handleDragStart"
          @click="handleSubmit"
        >
          <button class="btn w-full select-none rounded p-3 px-6 text-3xl">
            {{ t('buyButton') }}
          </button>
        </btn-jigsaw-puzzle>
      </div>

      <div class="text-center text-xs text-red-500">
        {{ isActive ? t('remainingTime', { seconds: timerCount }) : ' ' }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnJigsawPuzzle from '../btn-jigsaw-puzzle.vue'

const { t } = useI18n()

const MAX_TIMER_COUNT = 4

const jigsawPuzzleRef = useTemplateRef('jigsawPuzzleRef')

const timerCount = ref(MAX_TIMER_COUNT)
const { isActive, pause, resume } = useIntervalFn(() => {
  timerCount.value--

  if (timerCount.value <= 0) {
    reset()
  }
}, 1000, {
  immediate: false,
})

const ticketOptionList = computed(() => [
  {
    id: 't1',
    area: t('rockZone'),
    price: 5200,
    note: t('rockZoneNote'),
  },
  {
    id: 't2',
    area: t('vipZone'),
    price: 4200,
    note: t('vipZoneNote'),
  },
  {
    id: 't3',
    area: t('weirdZone'),
    price: 3200,
    note: t('weirdZoneNote'),
  },
])
const selectedTicketId = ref('')

function reset() {
  selectedTicketId.value = ''
  jigsawPuzzleRef.value?.scatter()
  pause()
  timerCount.value = MAX_TIMER_COUNT
}

function handleDragStart() {
  if (isActive.value) {
    return
  }
  resume()
}
function handleSubmit() {
  // eslint-disable-next-line no-alert
  alert(t('soldOut'))
  reset()
}
</script>

<style scoped lang="sass">
.btn
  background-color: light-dark(#444, #EEE)
  color: light-dark(#EEE, #444)
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

How It Works

Uses an SVG mask to implement the jigsaw splitting, and Pointer events to implement the drag-and-drop behavior.

Source Code

API

Props

interface Props {
  /** 按鈕內文字 */
  label?: string;
  /** 是否停用 */
  disabled?: boolean;
  /** 同 CSS z-index */
  zIndex?: number | string;
  /** 同 html tabindex */
  tabindex?: number | string;

  /** 拼圖行數 */
  rowCount?: number;
  /** 拼圖列數 */
  colCount?: number;
}

Emits

const emit = defineEmits<{
  /** 開始拖動 */
  dragStart: [piece: Piece, evt: PointerEvent];
  dragging: [piece: Piece, evt: PointerEvent];
  dragStop: [piece: Piece, evt: PointerEvent];
  completed: [];
  click: [];
}>()

Methods

defineExpose({
  /** 打散拼圖 */
  scatter,
  /** 自動完成 */
  autoComplete,
})

Slots

defineSlots<{
  /** 按鈕 */
  default?: (params: { isAllCompleted: boolean }) => unknown;
}>()

v0.60.0