Skip to content

求關注的按鈕 button

很會刷存在感的按鈕!◝( ゚ ∀。)◟

技術關鍵字

名稱 描述
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊
CSS 動畫基於 CSS transition 和 animation 實現
JS 動畫基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等
Anime.js輕量級 JavaScript 動畫函式庫

使用範例

基本用法

滑鼠靠近按鈕時會跑過來,若不在畫面內則會擠到邊緣湊熱鬧。

鱈魚冷凍櫃釘子戶
一條在冷凍櫃裡躺到懷疑魚生的鱈魚。沒有夢想,只有結霜。偶爾被拿起來看一眼,又被放回去,跟你的健身計畫一樣。
鱈魚的一生就是不斷被比較。比鮭魚便宜、比鯛魚沒名氣、比多利魚少一部皮克斯電影。唯一的優勢大概是炸起來還不錯,但說實話,什麼東西炸了不好吃?
驗明正身
學名:Gadus morhua綽號:冷凍櫃邊緣魚所在地:-18°C 的孤獨
性格描述
  • 存在感薄如魚片
  • 沒有刺,也沒有脾氣
  • 配什麼醬都行,沒有原則
人生目標
  • 被端上桌時有人拍照
  • 不再被當成「油魚」
  • 退休前至少上一次美食節目
查看範例原始碼
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>

電子報退訂

「留下來」按鈕會主動挽留訂戶。(◐‿◑)

查看範例原始碼
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>

原理

按鈕移動容器(carrierRef)會隨著滑鼠位置移動,並且在視窗邊界時會擠到邊緣湊熱鬧。

原始碼

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