Skip to content

Naughty Button button

A button that runs further and further away when disabled. ᕕ( ゚ ∀。)ᕗ

The more you try to catch it, the farther it runs, just like your cat. (._.`)

Tech Keywords

NameDescription
Vector MathMath operations for direction, acceleration, velocity, and more
Pointer EventsDetects pointer movement, clicks, hovers, and more, providing coordinates and target information
CSS AnimationImplemented with CSS transition and animation
IntersectionObserverDetects when elements enter or leave the viewport

Examples

Basic Usage

When the button is disabled and you hover, click, or press Enter on it, it starts running away!

View Example Source Code
vue
<template>
  <div class="example-wrap w-full flex flex-col gap-4">
    <div class="example-ctrl flex flex-col gap-4">
      <base-checkbox
        v-model="disabled"
        :label="t('disableButton')"
      />

      <base-input
        v-model="text"
        :placeholder="t('inputPlaceholder')"
        class="w-full"
      />
    </div>

    <div class="flex justify-center">
      <btn-naughty
        :label="t('naughtyButtonLabel')"
        class="font-bold"
        :disabled="disabled"
        z-index="30"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
import BtnNaughty from '../btn-naughty.vue'

const text = ref('')
const disabled = ref(true)

const { t } = useI18n()
</script>

Moving Distance

Specify maxDistanceMultiple to set the maximum moving distance multiplier (based on its own width/height). If the button runs out of the specified range or off the screen, it will automatically return to its original position.

View Example Source Code
vue
<template>
  <div class="w-full flex flex-col items-center justify-center gap-4 example-wrap">
    <base-input
      v-model="maxMultiple"
      type="number"
      outlined
      :label="t('倍數')"
    />

    <btn-naughty
      :label="t('按鈕')"
      class="font-bold"
      disabled
      :max-distance-multiple="maxMultiple"
      z-index="30"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseInput from '../../base-input.vue'
import BtnNaughty from '../btn-naughty.vue'

const { t } = useI18n()

const maxMultiple = ref(3)
</script>

Calling Methods

Besides its default behavior, you can also directly call methods to trigger actions.

View Example Source Code
vue
<template>
  <div class="w-full flex flex-col items-center justify-center gap-4 example-wrap">
    <div class="example-ctrl w-full flex gap-4">
      <base-btn
        :label="t('move')"
        @click="run"
      />
      <base-btn
        :label="t('back')"
        @click="back"
      />
    </div>

    <btn-naughty
      ref="btn"
      :label="t('buttonLabel')"
      z-index="30"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BtnNaughty from '../btn-naughty.vue'

const { t } = useI18n()

const btn = ref<InstanceType<typeof BtnNaughty>>()
function run() {
  btn.value?.run()
}
function back() {
  btn.value?.back()
}
</script>

Custom Button

You can use the default slot to customize the button's appearance.

Custom Button
View Example Source Code
vue
<template>
  <div class="flex justify-center example-wrap">
    <btn-naughty
      disabled
      z-index="30"
    >
      <div class="custom-button">
        {{ t('customButton') }}
      </div>
    </btn-naughty>
  </div>
</template>

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

const { t } = useI18n()
</script>

<style scoped lang="sass">
.custom-button
  background: #ff8345
  color: white
  padding: 0.5rem 1.5rem
  border-radius: 999rem
</style>

Custom Rubbing Effect

You ask if the rubbing effect can be customized? Of course! When have I ever said no?

Use the rubbing slot to customize the button's rubbing effect content.

It ran away
Custom Button
View Example Source Code
vue
<template>
  <div class="flex justify-center example-wrap">
    <btn-naughty
      disabled
      z-index="30"
    >
      <template #rubbing>
        <div class="rubbing">
          {{ t('啪!跑了') }}
        </div>
      </template>

      <template #default>
        <div class="btn">
          {{ t('自定義按鈕') }}
        </div>
      </template>
    </btn-naughty>
  </div>
</template>

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

const { t } = useI18n()
</script>

<style scoped lang="sass">
.btn
  padding: 0.5rem 1.5rem
  background: #26A69A
  border-radius: 999rem
  font-weight: bold
  color: white
  cursor: pointer

.rubbing
  padding: 0.5rem 1.5rem
  background: light-dark(#FEFEFE, #333)
  border-radius: 999rem
  border: 1px dashed light-dark(#777, #999)
  text-align: center
</style>

Slot Props

Using slot props allows for even more creative possibilities.

😗
Click here to win
View Example Source Code
vue
<template>
  <div class="flex justify-center example-wrap">
    <btn-naughty
      disabled
      z-index="30"
    >
      <template #rubbing="{ isRunning }">
        <div class="rubbing">
          {{ isRunning ? '😜' : '😗' }}
        </div>
      </template>

      <template #default="{ isRunning }">
        <div class="btn">
          {{ isRunning ? t('點不到咧') : t('點此中獎') }}
        </div>
      </template>
    </btn-naughty>
  </div>
</template>

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

const { t } = useI18n()
</script>

<style scoped lang="sass">
.btn
  padding: 0.5rem 1.5rem
  background: #26A69A
  border-radius: 999rem
  font-weight: bold
  color: white
  font-size: 1.25rem
  cursor: pointer

.rubbing
  padding: 0.5rem 1.5rem
  background: light-dark(#FEFEFE, #333)
  border-radius: 999rem
  border: 1px dashed light-dark(#777, #999)
  text-align: center
  font-size: 1.25rem
</style>

Form Example

Can't click it until you fill out the form! (╯•̀ὤ•́)╯

Unfinished
View Example Source Code
vue
<template>
  <div class="relative w-full flex justify-center example-wrap">
    <div class="max-w-[20rem] flex flex-col gap-4">
      <base-input
        v-model="form.username"
        :label="t('帳號 *')"
        class="w-full"
      />

      <base-input
        v-model="form.password"
        type="password"
        :label="t('密碼 *')"
        class="w-full"
      />

      <div class="mt-3 flex justify-center">
        <btn-naughty
          :label="t('送出表單')"
          :disabled
          z-index="30"
          @click="handleSubmit"
        >
          <template #rubbing>
            <div class="rubbing">
              {{ t('請完成表單') }}
            </div>
          </template>
        </btn-naughty>
      </div>
    </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-600 bg-opacity-90 text-white"
        @click="reset"
      >
        <span class="text-xl tracking-wide">
          {{ t('表單已送出!(*´∀`)~♥') }}
        </span>

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

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseInput from '../../base-input.vue'
import BtnNaughty from '../btn-naughty.vue'

const { t } = useI18n()

const form = ref({
  username: '',
  password: '',
})
const disabled = computed(() => {
  return form.value.username === '' || form.value.password === ''
})

const isSubmitted = ref(false)
function handleSubmit() {
  if (disabled.value) {
    return
  }
  isSubmitted.value = true
}

function reset() {
  isSubmitted.value = false

  form.value = {
    username: '',
    password: '',
  }
}
</script>

<style lang="sass" scoped>
.rubbing
  padding: 0.75rem
  color: light-dark(#ff7530, #ff9660)
  opacity: 0.8
  border: 1px dashed light-dark(#ff7530, #ff9660)
  border-radius: 0.2rem
  white-space: nowrap
  text-align: center

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

How it Works

When the mouse touches the button, it calculates the unit vector from the mouse position to the button's center. Based on this vector, it moves a distance equivalent to the button's size.

If the button moves off-screen, it automatically returns to its original position. This is implemented using IntersectionObserver.

📚 What is IntersectionObserver?

Caution! Σ(ˊДˋ;)

Please don't set overflow to hidden, otherwise the button will just poof disappear the moment it moves.

Source Code

API

Props

interface Props {
  /** 按鈕內文字 */
  label?: string;
  /** 是否停用 */
  disabled?: boolean;
  /** 同 CSS z-index */
  zIndex?: number | string;
  /** 最大移動距離,為按鈕尺寸倍數 */
  maxDistanceMultiple?: number;
  /** 同 html tabindex */
  tabindex?: number | string;
}

Emits

const emit = defineEmits<{
  (e: 'click'): void;
  /** 開始移動時 */
  (e: 'run'): void;
  /** 開始返回時 */
  (e: 'back'): void;
}>()

Methods

defineExpose({
  /** 移動 */
  run,
  /** 返回原點 */
  back,
  /** 是否正在移動 */
  isRunning,
})

Slots

defineSlots<{
  /** 按鈕 */
  default?: (props: { isRunning: boolean }) => unknown;
  /** 拓印 */
  rubbing?: (props: { isRunning: boolean }) => unknown;
}>()

v0.71.4