OSU Hit Button button
Just like the hit button in the game OSU, click the button with the right timing! (ゝ∀・)b
If you’re wondering what OSU is, you can take a look here.
Tech Keywords
| Name | Description |
|---|---|
| Pointer Events | Detects pointer movement, clicks, hovers, and more, providing coordinates and target information |
| JS Animation | JavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js |
| Anime.js | Lightweight JavaScript animation library |
Usage Examples
Basic Usage
After you touch the button, an approach circle will appear. When the circle becomes the same size as the original button, give it a solid hit! ੭ ˙ᗜ˙ )੭
View example source code
<template>
<div class="w-full flex flex-col gap-4 example-wrap">
<div class="flex justify-center">
<btn-osu-hit label="Submit" />
</div>
</div>
</template>
<script setup lang="ts">
import BtnOsuHit from '../btn-osu-hit.vue'
</script>Form Example
Only a Perfect judgement will trigger the event, and the reaction window is shortened—drive your coworkers crazy ᕕ( ゚ ∀。)ᕗ
- A delicious drink
- A relaxing lunch break
- A wonderful mood
View example source code
<template>
<div class="relative flex flex-col gap-3 border border-red-300 rounded-lg bg-red-100/50 p-6">
<span class="text-xl text-red-800 font-bold">
{{ t('dontOrderDrink') }}
</span>
<span class="mt-2 text-red-700">
{{ t('reminder') }}
</span>
<ul class="list-disc list-inside text-sm text-red-600 !m-0">
<li>{{ t('loss1') }}</li>
<li>{{ t('loss2') }}</li>
<li>{{ t('loss3') }}</li>
</ul>
<div class="flex justify-end gap-4">
<base-btn :label="t('cancelBtn')" />
<btn-osu-hit
:reaction-time="400"
@perfect="handleClick"
>
<base-btn
:label="t('confirmBtn')"
class="text-white active:scale-95 !border-none !bg-red-600"
/>
</btn-osu-hit>
</div>
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/90 duration-500"
:class="done ? ' opacity-100' : 'opacity-0'"
>
<span class="text-2xl text-white font-bold">
{{ t('doneTip') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BtnOsuHit from '../btn-osu-hit.vue'
const { t } = useI18n()
const done = ref(false)
function handleClick() {
done.value = true
setTimeout(() => {
done.value = false
}, 2000)
}
</script>Rhythm Challenge
Come and challenge it! ヾ(◍'౪`◍)ノ゙
View example source code
<template>
<div class="w-full flex flex-col gap-10 example-wrap">
<div class="flex flex-col gap-1 text-center text-sm">
<div>
{{ t('tip') }}
</div>
<div>
{{ t('tip2') }}
</div>
</div>
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<btn-osu-hit
v-for="item, i in btnList"
:key="i"
:ref="(ref: any) => item.ref = ref"
v-model:hit-result="item.hitResult"
v-model:is-start="item.isStart"
:label="item.label"
class="duration-500"
:class="{
'opacity-10 pointer-events-none': !item.isStart && i !== 0,
}"
:enable-enter-start="i === 0"
@start="start(i)"
/>
</div>
<div
class="coupon-btn"
:class="{ '!cursor-not-allowed opacity-20': !allPerfect }"
@click="handleCouponClick"
>
{{ t('couponBtn') }}
</div>
</div>
</template>
<script setup lang="ts">
import type { ComponentProps } from 'vue-component-type-helpers'
import { promiseTimeout } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnOsuHit from '../btn-osu-hit.vue'
type BtnProps = ComponentProps<typeof BtnOsuHit>
const { t } = useI18n()
const btnList = ref<Array<{
label: string;
isStart: boolean;
hitResult: BtnProps['hitResult'];
ref?: InstanceType<typeof BtnOsuHit>;
}>>([
{ label: t('perfect'), isStart: false, hitResult: undefined },
{ label: t('click'), isStart: false, hitResult: undefined },
{ label: t('just'), isStart: false, hitResult: undefined },
{ label: t('unlock'), isStart: false, hitResult: undefined },
{ label: t('coupon'), isStart: false, hitResult: undefined },
])
const anyStarted = computed(
() => btnList.value.some(({ isStart }) => isStart),
)
const allPerfect = computed(
() => !btnList.value.some(({ hitResult }) => hitResult !== 'perfect'),
)
function start(index: number) {
if (index !== 0 || anyStarted.value) {
return
}
btnList.value.forEach(async (item, i) => {
item.hitResult = undefined
if (i === 0) {
return
}
await promiseTimeout(i * 1000)
item.ref?.startBeat()
})
}
function handleCouponClick() {
if (!allPerfect.value) {
return
}
window.open('https://165.npa.gov.tw/', '_blank')
}
</script>
<style scoped>
.coupon-btn {
display: inline-block;
padding: 12px 24px;
background: linear-gradient(135deg, #ff7a00, #ff3d00);
color: #fff;
font-size: 30px;
font-weight: bold;
border-radius: 12px;
text-align: center;
cursor: pointer;
transition: 0.25s;
box-shadow: 0 4px 10px rgba(255, 100, 0, 0.4);
user-select: none;
}
.coupon-btn:hover {
transform: translateY(-3px);
box-shadow: 0 6px 14px rgba(255, 100, 0, 0.5);
}
.coupon-btn:active {
transform: translateY(0);
box-shadow: 0 3px 8px rgba(255, 100, 0, 0.35);
}
</style>Combo Unlock
You can use slots to customize how the judgement result is displayed.
View example source code
<template>
<div class="relative w-full flex flex-col gap-10 example-wrap">
<div
class="flex flex-col gap-3"
:class="{ 'opacity-40 pointer-events-none': !isUnlock }"
>
<div class="text-3xl font-bold">
{{ t('complaintFeedbackForm') }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ t('complaintFeedbackFormTip') }}
</div>
<div class="flex justify-center border border-gray-200 rounded-lg p-3">
<textarea
:placeholder="t('inputPlaceholder')"
class="min-h-[30vh] w-full"
/>
</div>
<base-btn
:label="t('submitBtn')"
@click="handleSubmit"
/>
</div>
<div
v-if="!isUnlock"
class="mask absolute inset-0 flex flex-col items-center justify-center gap-5 rounded-xl"
>
<div class="flex flex-col gap-3 text-center">
<div class="text-xl font-bold">
{{ t('unlockTip') }}
</div>
<div class="text-sm opacity-50">
{{ t('unlockTip2', { unlockComboCount: UNLOCK_COMBO_COUNT }) }}
</div>
</div>
<btn-osu-hit
ref="btnRef"
v-model:is-start="isStart"
:enable-enter-start="!isStart"
:label="t('unlockBtn')"
v-on="eventMap"
>
<template #perfect>
<div class="result-text pointer-events-none absolute inset-0 flex-center text-3xl font-bold">
{{ comboCount }} combo!
</div>
</template>
<template #great>
<div class="result-text pointer-events-none absolute inset-0 flex-center text-3xl font-bold">
{{ comboCount }} combo!
</div>
</template>
</btn-osu-hit>
</div>
</div>
</template>
<script setup lang="ts">
import { promiseTimeout, whenever } from '@vueuse/core'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BtnOsuHit from '../btn-osu-hit.vue'
const UNLOCK_COMBO_COUNT = 20
const { t } = useI18n()
const btnRef = useTemplateRef('btnRef')
const comboCount = ref(0)
const isUnlock = ref(false)
whenever(
() => comboCount.value >= UNLOCK_COMBO_COUNT,
() => isUnlock.value = true,
)
const isStart = ref(false)
const eventMap = {
async perfect() {
comboCount.value++
await promiseTimeout(500)
btnRef.value?.startBeat()
},
async great() {
comboCount.value++
await promiseTimeout(500)
btnRef.value?.startBeat()
},
good() {
comboCount.value = 0
},
miss() {
comboCount.value = 0
},
}
function handleSubmit() {
// eslint-disable-next-line no-alert
alert(t('thanks'))
}
</script>
<style scoped lang="sass">
.result-text
animation: result-text 0.6s forwards
-webkit-text-stroke: 5px white
paint-order: stroke fill
text-wrap: nowrap
color: #FFD700
-webkit-text-stroke: 5px #f77816
font-weight: 900
@keyframes result-text
0%
opacity: 0
transform: scale(0.8)
10%, 50%
opacity: 1
transform: scale(1)
100%
opacity: 0
.mask
backdrop-filter: blur(2px)
</style>How It Works
Anime.js is used to implement the animation, and after clicking, the hit result is determined based on the current scale value.
Source Code
API
Props
interface Props {
label?: string;
/** 反應時間,單位:毫秒 */
reactionTime?: number;
/** pointer enter 是否觸發 start */
enableEnterStart?: boolean;
}Emits
interface Emits {
start: [];
perfect: [];
great: [];
good: [];
miss: [];
}Methods
interface Expose {
/** 開始打擊,會出現 Approach Circle */
startBeat: () => void;
}Slots
interface Slots {
default?: () => unknown;
perfect?: () => unknown;
great?: () => unknown;
good?: () => unknown;
miss?: () => unknown;
}