Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

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. (._.`)

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

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

<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>

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.

<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>

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

<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>

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

Custom Button
<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>

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
<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>

Using slot props allows for even more creative possibilities.

😗
Click here to win
<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>

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

Unfinished
<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>

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?

interface Props {
/** 按鈕內文字 */
label?: string;
/** 是否停用 */
disabled?: boolean;
/** 同 CSS z-index */
zIndex?: number | string;
/** 最大移動距離,為按鈕尺寸倍數 */
maxDistanceMultiple?: number;
/** 同 html tabindex */
tabindex?: number | string;
}
const emit = defineEmits<{
(e: 'click'): void;
/** 開始移動時 */
(e: 'run'): void;
/** 開始返回時 */
(e: 'back'): void;
}>()
interface Expose {
/** 移動 */
run: () => void;
/** 返回原點 */
back: () => void;
/** 是否正在移動 */
isRunning: Ref<boolean>;
}
defineSlots<{
/** 按鈕 */
default?: (props: { isRunning: boolean }) => unknown;
/** 拓印 */
rubbing?: (props: { isRunning: boolean }) => unknown;
}>()