Skip to content

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

v0.54.2