Skip to content

Attention Seeker Button button

A button that really knows how to get your attention! ◝( ゚ ∀。)◟

Usage Examples

Basic Usage

When the mouse approaches the button, it rushes over. If it's not in the viewport, it squeezes to the edge to join the fun.

CodfishFreezer Squatter
A codfish lying in the freezer long enough to question the meaning of existence. No dreams, just frost. Occasionally picked up, inspected, and put back — much like your gym membership.
Life as a cod is one long comparison. Cheaper than salmon, less famous than tilapia, and missing a Pixar movie unlike dory. The only edge? Tastes decent when deep-fried. Then again, what doesn't?
Identification
Scientific name: Gadus morhuaA.K.A: The Forgettable FishLocation: -18°C solitude
Personality
  • Presence thinner than a fish fillet
  • No bones, no backbone, no opinions
  • Goes with any sauce — zero principles
Life Goals
  • Get photographed at least once before being eaten
  • Stop being called 'that white fish'
  • Appear on a cooking show before retirement
View example source code
vue
<template>
  <div class="codfish-profile example-wrap w-full flex flex-col gap-0 border rounded-xl px-7 py-8">
    <!-- 標題區 -->
    <div class="mb-5 flex flex-col gap-3">
      <div class="flex items-baseline gap-3">
        <span class="text-[clamp(1.5rem,2vw+0.5rem,2rem)] font-extrabold leading-tight tracking-tight">{{ t('name') }}</span>
        <span class="profile-badge whitespace-nowrap rounded px-2.5 py-0.5 text-xs font-medium tracking-wide">
          {{ t('badge') }}
        </span>
      </div>

      <span class="max-w-[65ch] text-sm text-[var(--_text-sub)] leading-relaxed">
        {{ t('intro') }}
      </span>
    </div>

    <!-- 描述區 -->
    <span class="mb-7 max-w-[65ch] text-[0.9375rem] leading-loose">
      {{ t('description') }}
    </span>

    <!-- 資訊卡片 -->
    <div class="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] mb-8 gap-3">
      <div class="info-card">
        <span class="info-label">
          {{ t('idCardTitle') }}
        </span>
        <div class="info-body">
          <span>{{ t('scientificName') }}<em>Gadus morhua</em></span>
          <span>{{ t('nickname') }}</span>
          <span>{{ t('habitat') }}</span>
        </div>
      </div>

      <div class="info-card">
        <span class="info-label">
          {{ t('personalityTitle') }}
        </span>
        <ul class="info-list">
          <li>{{ t('personality1') }}</li>
          <li>{{ t('personality2') }}</li>
          <li>{{ t('personality3') }}</li>
        </ul>
      </div>

      <div class="info-card">
        <span class="info-label">
          {{ t('goalsTitle') }}
        </span>
        <ul class="info-list">
          <li>{{ t('goal1') }}</li>
          <li>{{ t('goal2') }}</li>
          <li>{{ t('goal3') }}</li>
        </ul>
      </div>
    </div>

    <!-- 按鈕區 -->
    <div class="flex justify-center pt-1">
      <btn-attention-seeker
        :label="t('buyButton')"
        :top-offset="topOffset"
        z-index="19"
        @click="handleClick"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useElementSize, useWindowSize } from '@vueuse/core'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnAttentionSeeker from '../btn-attention-seeker.vue'

const { t } = useI18n()

const windowSize = reactive(useWindowSize())

const navRef = ref<HTMLHeadElement>()
const localNavRef = ref<HTMLDivElement>()
onMounted(() => {
  navRef.value = document.querySelector<HTMLHeadElement>('.VPNav') ?? undefined
  localNavRef.value = document.querySelector<HTMLDivElement>('.VPLocalNav') ?? undefined
})

const navEl = reactive(useElementSize(navRef))
const localNavEl = reactive(useElementSize(localNavRef))

const topOffset = computed(() => {
  if (windowSize.width < 960) {
    return localNavEl.height ?? 0
  }
  return (navEl.height ?? 0) + (localNavEl.height ?? 0)
})

function handleClick() {
  window.open('https://codlin.me', '_blank')
}
</script>

<style lang="sass" scoped>
.codfish-profile
  --_bg: light-dark(#fafafa, #1c1c20)
  --_text: light-dark(#2c2a25, #ddd9d0)
  --_text-sub: light-dark(#6b6660, #9a958d)
  --_accent: light-dark(#1a6b5a, #4ec9a8)
  --_card-bg: light-dark(#f5f4f2, #26262b)
  --_card-border: light-dark(#dedbd4, #35353b)
  --_badge-bg: light-dark(#e0f0ec, #1e3a33)
  --_badge-text: light-dark(#1a6b5a, #4ec9a8)
  background: var(--_bg)
  color: var(--_text)

.profile-badge
  background: var(--_badge-bg)
  color: var(--_badge-text)

.info-card
  @apply flex flex-col gap-2.5 rounded-md p-4 transition-colors duration-200
  background: var(--_card-bg)
  border: 1px solid var(--_card-border)

  &:hover
    background: light-dark(#e8e7e2, #2c2c32)

.info-label
  @apply text-[0.6875rem] font-bold uppercase tracking-widest
  color: var(--_accent)

.info-body
  @apply flex flex-col gap-1 text-[0.8125rem] leading-relaxed
  color: var(--_text-sub)

  em
    @apply italic
    color: var(--_accent)

.info-list
  @apply m-0 flex flex-col list-none gap-1.5 p-0 text-[0.8125rem] leading-normal
  color: var(--_text-sub)

  li
    @apply relative pl-3.5
    &::before
      @apply absolute left-0 font-bold
      content: '·'
      color: var(--_accent)
</style>

Newsletter Unsubscribe

The "Stay" button actively chases your mouse, blocking the unsubscribe flow.

View example source code
vue
<template>
  <div class="newsletter-unsubscribe example-wrap relative w-full flex flex-col items-center overflow-hidden border rounded-xl">
    <div class="max-w-lg w-full flex flex-col gap-8 px-4 py-10">
      <!-- 魚的表情 -->
      <div class="flex flex-col items-center gap-3">
        <div
          class="fish-face select-none text-6xl transition-all duration-500"
          :class="{ 'fish-face--sad': selectedReason }"
        >
          {{ fishExpression }}
        </div>

        <div class="text-center">
          <div class="text-2xl font-bold tracking-tight">
            {{ t('title') }}
          </div>
          <span class="mt-2 inline-block text-sm opacity-50">
            {{ t('subtitle') }}
          </span>
        </div>
      </div>

      <!-- 退訂原因 -->
      <div class="flex flex-col gap-3">
        <span class="text-xs font-medium tracking-wide uppercase opacity-60">
          {{ t('reason') }}
        </span>

        <div class="flex flex-col gap-2">
          <label
            v-for="item in reasonList"
            :key="item.key"
            class="reason-option"
            :class="{ 'reason-option--selected': selectedReason === item.key }"
          >
            <input
              v-model="selectedReason"
              type="radio"
              :value="item.key"
              name="reason"
              class="sr-only"
            >
            <span class="reason-dot" />
            <span class="text-sm">{{ item.label }}</span>
          </label>
        </div>
      </div>

      <!-- 按鈕區 -->
      <div class="flex flex-col items-center gap-6">
        <btn-attention-seeker
          :top-offset="topOffset"
          z-index="19"
          :follow-distance="100"
        >
          <button
            class="stay-btn"
            @click="handleStay"
          >
            {{ t('stayButton') }}
          </button>
        </btn-attention-seeker>

        <button
          class="unsubscribe-btn"
          @click="handleUnsubscribe"
        >
          {{ t('unsubscribeButton') }}
        </button>
      </div>
    </div>

    <!-- 退訂成功覆蓋 -->
    <transition name="fade">
      <div
        v-if="unsubscribed"
        class="overlay absolute inset-0 z-40 flex flex-col items-center justify-center gap-4 backdrop-blur-sm"
        @click="reset"
      >
        <span class="text-5xl">
          🐟
        </span>
        <span class="text-lg font-bold tracking-tight">
          {{ t('unsubscribedMessage') }}
        </span>
        <span class="cursor-pointer text-xs opacity-40 transition-opacity hover:opacity-70">
          {{ t('resetHint') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { useElementSize, useWindowSize } from '@vueuse/core'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BtnAttentionSeeker from '../btn-attention-seeker.vue'

const { t } = useI18n()

const windowSize = reactive(useWindowSize())

const navRef = ref<HTMLHeadElement>()
const localNavRef = ref<HTMLDivElement>()
onMounted(() => {
  navRef.value = document.querySelector<HTMLHeadElement>('.VPNav') ?? undefined
  localNavRef.value = document.querySelector<HTMLDivElement>('.VPLocalNav') ?? undefined
})

const navEl = reactive(useElementSize(navRef))
const localNavEl = reactive(useElementSize(localNavRef))

const topOffset = computed(() => {
  if (windowSize.width < 960) {
    return localNavEl.height ?? 0
  }
  return (navEl.height ?? 0) + (localNavEl.height ?? 0)
})

const selectedReason = ref('')
const unsubscribed = ref(false)

const fishExpression = computed(() => {
  if (unsubscribed.value)
    return '🥺'
  if (selectedReason.value)
    return '😢'
  return '🐟'
})

const reasonList = computed(() => [
  { key: 'too-oily', label: t('reasonTooOily') },
  { key: 'not-relevant', label: t('reasonNotRelevant') },
  { key: 'never-subscribed', label: t('reasonNeverSubscribed') },
  { key: 'other', label: t('reasonOther') },
])

function handleStay() {
  // eslint-disable-next-line no-alert
  alert('一個都不能走!(「・ω・)「')
}

function handleUnsubscribe() {
  unsubscribed.value = true
}

function reset() {
  unsubscribed.value = false
  selectedReason.value = ''
}
</script>

<style lang="sass" scoped>
.fish-face
  transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)

.fish-face--sad
  transform: scale(1.15) rotate(-5deg)

.reason-option
  display: flex
  align-items: center
  gap: 10px
  padding: 10px 14px
  border-radius: 8px
  cursor: pointer
  transition: all 0.2s
  background: light-dark(rgba(0, 0, 0, 0.02), rgba(255, 255, 255, 0.03))
  &:hover
    background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.06))

.reason-option--selected
  background: light-dark(rgba(220, 80, 60, 0.06), rgba(220, 80, 60, 0.12))

.reason-dot
  width: 16px
  height: 16px
  flex-shrink: 0
  border-radius: 50%
  border: 2px solid light-dark(#ccc, #444)
  transition: all 0.2s
  .reason-option--selected &
    border-color: light-dark(#c44, #e66)
    background: light-dark(#c44, #e66)
    box-shadow: inset 0 0 0 3px light-dark(#fafaf8, #1a1a1e)

.stay-btn
  padding: 12px 32px
  border-radius: 10px
  font-weight: 700
  font-size: 15px
  color: #fff
  background: light-dark(#2a9d5c, #34b86a)
  transition: all 0.2s
  &:hover
    background: light-dark(#238a50, #2ca85e)
    transform: translateY(-1px)
  &:active
    transform: scale(0.97) translateY(0)

.unsubscribe-btn
  padding: 4px 0
  font-size: 12px
  color: light-dark(#aaa, #555)
  background: none
  border: none
  cursor: pointer
  text-decoration: underline
  text-underline-offset: 3px
  transition: color 0.2s
  &:hover
    color: light-dark(#888, #777)

.overlay
  background: light-dark(rgba(250, 250, 248, 0.92), rgba(26, 26, 30, 0.92))

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

How It Works

The button's carrier container (carrierRef) follows the mouse position, and when near the viewport boundaries, it squeezes to the edge to join the fun.

Source Code

API

Props

interface Info {
  width: number;
  height: number;
  x: number;
  y: number;
}

interface Props {
  /** 按鈕內文字 */
  label?: string;
  /** 是否停用 */
  disabled?: boolean;
  /** 同 CSS z-index */
  zIndex?: number | string;
  /** 跟隨距離,當距離小於此值時開始跟隨 */
  followDistance?: number | ((info: Info) => number);
  /** 上方偏移量,避免被 header 遮擋 */
  topOffset?: number;
  /** 下方偏移量,避免被 footer 遮擋 */
  bottomOffset?: number;
}

Slots

defineSlots<{
  /** 按鈕 */
  default?: () => unknown;
}>()

v0.63.0