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

Anamorphosis Button button

You need to rotate the button to a certain angle to display it normally, which can effectively block humans ( •̀ ω •́ )✧

Passerby: "Shouldn't it block robots! (╯°Д°)╯︵ ┻━┻"

Example

Basic Usage

Rotate and restore the button!

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-anamorphosis
        :label="t('按鈕')"
        @click="handleClick"
      />
    </div>
  </div>
</template>

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

const { t } = useI18n()

function handleClick() {
  // eslint-disable-next-line no-alert
  alert(t('點擊成功'))
}
</script>

Evil Unsubscribe

Stopping users from escaping A sweet design to prevent misclicks! ◝( •ω• )◟

Thank you for subscribing to Super Premium!

$999 will be automatically deducted every month

If you really don't want to support us anymore, you can try clicking here...
(You must align the perspective to click)

View Example Source Code
vue
<template>
  <div
    class="relative w-full flex flex-col items-center justify-center gap-6 overflow-hidden border border-gray-200 rounded-xl p-6"
  >
    <div class="text-center">
      <p class="mb-2 text-2xl font-bold">
        {{ t('感謝您訂閱超級會員!') }}
      </p>
      <p class="">
        {{ t('每月將自動扣款 $999') }}
      </p>
    </div>

    <button
      class="transform rounded-full from-green-400 to-green-600 bg-gradient-to-r px-10 py-4 text-lg text-white font-bold shadow-lg transition active:scale-95 hover:scale-105 hover:from-green-500 hover:to-green-700"
      @click="handleUpgrade"
    >
      🌟 {{ t('升級為終身會員 ($9999)') }} 🌟
    </button>

    <div class="mt-3 flex flex-col items-center gap-4 text-center">
      <p class="text-xs text-gray-400">
        {{ t('如果您真的不想再支持我們了,您可以嘗試點擊這裡...') }}<br>
        <span class="text-[10px] text-gray-400">
          ({{ t('視角轉正才能點擊哦') }})
        </span>
      </p>

      <btn-anamorphosis
        ref="btnRef"
        @click="handleUnsubscribe"
      >
        <button
          class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-400 underline transition-colors hover:bg-gray-200 hover:text-gray-600"
        >
          {{ t('取消訂閱') }}
        </button>
      </btn-anamorphosis>
    </div>

    <transition name="opacity">
      <div
        v-if="isSubmitted"
        class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-800 bg-opacity-95 text-white"
        @click="reset"
      >
        <span class="text-xl font-bold tracking-wide">
          {{ t('退訂成功') }}
        </span>

        <span class="cursor-pointer text-xs text-gray-400 transition-colors hover:text-white">
          {{ t('點一下再來一次') }}
        </span>
      </div>
    </transition>
  </div>
</template>

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

const { t } = useI18n()

const btnRef = useTemplateRef('btnRef')
const isSubmitted = ref(false)

function handleUnsubscribe() {
  isSubmitted.value = true
}

function handleUpgrade() {
  open('https://codlin.me', '_blank')
}

function reset() {
  isSubmitted.value = false
  btnRef.value?.init()
}
</script>

<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
  opacity: 0 !important
</style>

Principle

The core concept comes from Visual Anamorphosis, where a complete picture is broken and misaligned in 3D space, meaning you can only see the original complete image from a specific viewing angle (i.e. the front view). The implementation details are as follows:

Polygon Slicing and Misalignment:

  • CSS clip-path is used to randomly cut the button into multiple irregular polygon slices (rows and columns are adjustable via rowCount and colCount).
  • Using the CSS 3D transform: translateZ, these slices are distributed across the Z-axis in a staggered manner (depth adjustable via zSpread). When viewed from non-front angles, the slices look shattered due to parallax. ◝( •ω• )◟

Cursor Lock and Perspective Rotation:

  • Using VueUse's usePointerLock and useMouse, the cursor is locked when the user presses (pointerdown) the element, and the relative movement of the cursor (movementX, movementY) is captured.
  • These movements are converted into the 3D rotation angles of the component container (rotateX, rotateY), allowing users to manually "rotate" this broken object.

Visual Interference:

  • To increase difficulty and blocking effect, in addition to the main slices, multiple sets of "interference slices" rotated at different angles have been duplicated.
  • As the user rotates the object closer to the target angle (rotateX and rotateY approach a multiple of 0), the opacity (noiseOpacity) of these interference slices decreases correspondingly, serving as visual feedback to guide users to the correct angle.

Auto-snap and Unlock:

  • When the difference between the current angle and the front view is extremely small (interference slice opacity is extremely low), the cursor lock is automatically released, triggering the "auto-align" (isAligning), forcing the rotation angle to 0 smoothly via CSS Transition.
  • Once the alignment animation finishes (isDone), all spatial misalignment effects and noise are hidden. Only then does the true underlying button lose the pointer-events-none state, allowing users to interact with it, such as clicking.

Source Code

API

Props

interface Props {
  label?: string;
  /** 行數 */
  rowCount?: number;
  /** 列數 */
  colCount?: number;
  /** 切片之間的 Z 軸間距 (px) */
  zSpread?: number;
  /** 旋轉接近歸零時,是否淡出干擾切片。設為 false 則干擾切片永遠不透明 */
  noiseFadeOut?: boolean;
}

Emits

const emit = defineEmits<{
  click: [];
  unlock: [];
}>()

Methods

interface Expose {
  init: () => void;
}

Slots

interface Slots {
  default?: () => unknown;
}

v0.60.0