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.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;
}>()