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.
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
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-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 ᕕ( ゚ ∀。)ᕗ
Are you really not ordering a drink? ( ・ิω・ิ)Friendly reminder, you're about to miss out on:
- A delicious drink
- A relaxing lunch break
- A wonderful mood
Operation completed (╥ω╥`)
View example source code
vue
<template>
<div class="relative flex flex-col gap-3 border border-red-300 rounded-lg bg-red-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! ヾ(◍'౪`◍)ノ゙
Press the buttons in order according to the rhythm
All Perfect judgements to unlock 90% discount coupon! ੭ ˙ᗜ˙ )੭
✨ Get Coupon
View example source code
vue
<template>
<div class="w-full flex flex-col gap-10 border border-gray-200 rounded-xl p-6">
<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.
Complaint Feedback Form
If you have any concerns, please fill in your意见 here, we will handle your problem as soon as possible.
Combo the "Unlock" button to unlock the complaint feedback form
20 combo to unlock! You can do it!(ゝ∀・)b
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"
:class="{ 'opacity-40 pointer-events-none': !isUnlock }"
>
<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
: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;
}