每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
求關注的按鈕
button很會刷存在感的按鈕!◝( ゚ ∀。)◟
| 名稱 | 描述 |
|---|---|
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
| CSS 動畫 | 基於 CSS transition 和 animation 實現 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
滑鼠靠近按鈕時會跑過來,若不在畫面內則會擠到邊緣湊熱鬧。
鱈魚冷凍櫃釘子戶
一條在冷凍櫃裡躺到懷疑魚生的鱈魚。沒有夢想,只有結霜。偶爾被拿起來看一眼,又被放回去,跟你的健身計畫一樣。驗明正身
學名:Gadus morhua綽號:冷凍櫃邊緣魚所在地:-18°C 的孤獨
性格描述
- 存在感薄如魚片
- 沒有刺,也沒有脾氣
- 配什麼醬都行,沒有原則
人生目標
- 被端上桌時有人拍照
- 不再被當成「油魚」
- 退休前至少上一次美食節目
<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 } 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 navRef = ref<HTMLHeadElement>()const localNavRef = ref<HTMLDivElement>()onMounted(() => { // 文件站的頁首固定在上方,窄螢幕時頁首下面還有一條固定的目錄列 navRef.value = document.querySelector<HTMLHeadElement>('header.header') ?? undefined localNavRef.value = document.querySelector<HTMLDivElement>('mobile-starlight-toc nav') ?? undefined})
const navEl = reactive(useElementSize(navRef))const localNavEl = reactive(useElementSize(localNavRef))
// 目錄列在寬螢幕隱藏,量到的高度是 0,直接相加即可const topOffset = computed(() => (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>「留下來」按鈕會主動挽留訂戶。(◐‿◑)
<template> <div class="example-wrap newsletter-unsubscribe 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 } 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 navRef = ref<HTMLHeadElement>()const localNavRef = ref<HTMLDivElement>()onMounted(() => { // 文件站的頁首固定在上方,窄螢幕時頁首下面還有一條固定的目錄列 navRef.value = document.querySelector<HTMLHeadElement>('header.header') ?? undefined localNavRef.value = document.querySelector<HTMLDivElement>('mobile-starlight-toc nav') ?? undefined})
const navEl = reactive(useElementSize(navRef))const localNavEl = reactive(useElementSize(localNavRef))
// 目錄列在寬螢幕隱藏,量到的高度是 0,直接相加即可const topOffset = computed(() => (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)會隨著滑鼠位置移動,並且在視窗邊界時會擠到邊緣湊熱鬧。
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;}defineSlots<{ /** 按鈕 */ default?: () => unknown;}>()