求關注的按鈕 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;
}>()