七彩殘影包裝器 wrapper
抓著甩、捲一捲,包起來的內容都會在畫面上留下五彩繽紛的殘影。ᕕ( ゚ ∀。)ᕗ
技術關鍵字
| 名稱 | 描述 |
|---|---|
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| Canvas 2D API | 基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形 |
| Canvas Filter | 在 Canvas 上直接套用 CSS 濾鏡,例如模糊、色相旋轉 |
| Canvas 合成模式 | 控制新圖形與畫布既有內容的疊合方式,例如相加、只塗在既有像素上 |
| CSS mix-blend-mode | 指定元素與底下畫面的混色方式,例如相加發光的 plus-lighter、壓暗的 multiply |
| Element getBoundingClientRect() | 取得元素相對視窗的座標與尺寸 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
| 彈簧阻尼系統 | 以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋 |
| IntersectionObserver | 偵測元素是否進入或離開視窗 |
使用範例
基本用法
捲動頁面、抓著甩動都會留下殘影,放手後元素會彈回原位。

查看範例原始碼
<template>
<div class="example-wrap w-full flex flex-col items-center gap-10 py-10">
<wrapper-tracers :ref="echoRefList.set">
<img
src="/low/profile.webp"
alt=""
class="w-40 border-[0.25rem] rounded-full object-cover"
>
</wrapper-tracers>
<wrapper-tracers :ref="echoRefList.set">
<div class="text-center text-3xl font-bold">
{{ t('codfish') }}
</div>
</wrapper-tracers>
<wrapper-tracers :ref="echoRefList.set">
<div class="card border rounded p-6">
<div class="mt-2 max-w-[17rem]">
{{ t('codfishDescription') }}
</div>
</div>
</wrapper-tracers>
</div>
</template>
<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import { useData } from 'vitepress'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperTracers from '../wrapper-tracers.vue'
const { t } = useI18n()
const data = useData()
const echoRefList = useTemplateRefsList<
InstanceType<typeof WrapperTracers>
>()
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
echoRefList.value.forEach((echoRef) => {
echoRef.refresh()
})
})
</script>
<style scoped lang="sass">
.card
background: light-dark(#EEE, #333)
</style>原理
先拍一張照片
snapdom 把插槽內容拍成畫布,每張殘影都從這張複製,原始 DOM 照常顯示與互動。拍照有兩個坑。snapdom 預設會乘上裝置像素比,畫布卻只畫一倍,色階畫布因此大上十幾倍,逐像素上色與蓋章取樣都跟著變貴,所以把 dpr 鎖在 1。拍照前得暫時改寫元素的 inline style,這本身就是一筆屬性變動,MutationObserver 看到就再拍一次,永遠停不下來,寫完立刻把自己產生的紀錄吃掉才擋得住。
拍出來的畫布不一定帶 CSS 圓角,所以擷取後照元素外框,用 destination-out 挖掉圓角外的四個角。
殘影是元素過去的位置
每幀用 getBoundingClientRect() 記下元素的視窗座標,跟上一幀相減就是殘影的位移,容器捲動、sticky、元素自己跑動畫都留得下來。畫布用 position: fixed 鋪滿視窗,不佔版面、不受祖先裁切,視窗座標可直接當繪圖座標。
一頁通常放不只一個包裝器,量測的寫法直接決定成本會不會跟著實例數翻倍。各實例若各自「讀座標、寫樣式」交錯執行,後面的實例每次讀取都得等版面重算。因此同頁所有實例共用一個 requestAnimationFrame,先全部讀完再統一寫入,每幀只重算一次。元素靜止、畫布乾淨時,每幀唯一的工作只剩偵測位置有沒有變,量測就降到每三幀一次,再靜止五秒拉到每十二幀。捲動與按下會立刻喚醒,降頻只替純 CSS 動畫這種沒有事件可依靠的情況兜底。
留著慢慢揮發
畫布不清空,每幀用 destination-out 扣掉一點 alpha,捲動與拖曳就變成光繪。蓋半透明色塊會把殘影染色,換個背景就破功。揮發只填還在 trailDecay 內的區塊,否則填色範圍會漲到整個視窗而掉幀。
畫布跟視窗一樣大,一張就要好幾 MB,多數時間卻全透明,所以要畫才配置,殘影散完或元素捲出畫面就把整塊像素還回去。回來時順便清掉舊座標,否則離開期間的位移會在第一幀畫成一條橫跨整頁的直線。
沿路徑等距蓋章
一幀蓋一張,移動越快間距越開,尾巴會斷成一排印章。改以距離決定張數,相鄰殘影固定相隔 stampGap,maxStampCount 當上限。甩得極快時單幀填色量會超過整張畫布,元素越大越吃虧,因此再以畫布面積的一半當預算,超過就自動放寬間距。那種速度下尾巴本來就又長又淡,少幾張看不出來。
蓋得密就會疊成實心色塊。n 層透明度 a 疊起來得到 1 - (1 - a)^n,反解就知道單張該多淡。疊加期間殘影還在揮發,早蓋的幾張已經淡掉,所以層數要再乘上元素走完自身長度這段時間的衰減係數,否則會補償過頭。
換掉顏色
白色卡片、灰階照片沒有彩度,轉色相還是灰的,所以顏色整個換掉。colorMode 有三種:
- 漸層映射(預設):用亮度查 256 階色帶,明暗留得下來,再用
hue-rotate()轉色相。色帶各色階亮度要接近且夠高,否則深色文字查完還是深色。 - RGB 色散:拆成紅、綠、藍三張畫布,沿運動方向錯開相加,只有邊緣留下彩邊。方向分階預先烘好,畫時查表。
- 純色平塗:用
source-atop疊純色,色相依走過的距離變化。
前兩種要讀像素,跨網域圖片會汙染畫布,這時退回原色。glow 打開後改用 lighter 相加,密處爆白成霓虹。
抓著甩,跟著變形
按下後位移超過四像素才算拖曳,單純點擊照常觸發。pointermove 掛在 window,手指跑出元素也追得到;不用 Pointer Capture 是因為它會讓文字選不起來。放手後跑彈簧阻尼回原位。拖曳期間擋掉 selectstart 與 dragstart,觸控只攔水平方向,垂直留給頁面捲動。
移動時元素沿運動方向拉長、垂直方向壓扁,也就是 squash and stretch。先轉到運動方向縮放,再轉回來,任意角度都對。
定點放在運動方向的前緣,前緣咬住、身體往後拉長,才像被拖著走;放中央會像整塊在呼吸。拖曳時定點改到抓取點,指下那點才不會滑掉。速度先做阻尼平滑,手停後還留一點餘韻。
原始碼
API
Props
/** 畫布與底下內容的混合模式 */
type EchoBlendMode = 'normal' | 'screen' | 'plus-lighter' | 'lighten' | 'overlay' | 'difference'
/** 殘影的上色方式 */
type EchoColorMode = 'gradient' | 'chromatic' | 'rainbow'
interface Props {
/**
* 關閉後只剩原本的內容,不留任何殘影。
*
* @default true
*/
enabled?: boolean;
/**
* 可否用滑鼠或手指抓著甩。
*
* 放手後依甩動速度做慣性,再以彈簧回到原位,沿途自然拉出一整條殘影。
* 觸控只攔截水平方向,垂直留給頁面捲動。
* 從輸入框、按鈕、連結上按下的手勢不會被攔截。
*
* @default true
*/
draggable?: boolean;
/**
* 殘影揮發所需的時間(ms)。
*
* 畫布不會每幀清空,而是每幀淡一點,捲動與拖曳因此像在頁面上光繪。
* 調短就回到一般的短尾巴,調長則痕跡留得久,畫面越玩越濃。
*
* @default 900
*/
trailDecay?: number;
/**
* 相鄰兩張殘影的間距(px)。
*
* 這個值決定尾巴連不連續。移動越快,元件就在這一幀的路徑上多蓋幾張,
* 讓相鄰殘影始終保持這個間距,不會拉開成一格一格的印章。
*
* @default 6
*/
stampGap?: number;
/**
* 單幀最多疊幾張殘影。
*
* 甩動速度沒有上限,張數必須有,否則一次甩出去就得畫上百張全尺寸的圖。
* 到達上限後間距會自動放寬。
*
* @default 32
*/
maxStampCount?: number;
/**
* 上色方式。
*
* - `gradient`:用亮度查一張漸層,保留內容原本的明暗層次,出來是油光質感。
* 白色卡片、灰階照片也照樣出得了顏色。
* - `chromatic`:把殘影拆成紅綠藍三份,沿運動方向錯開再相加。
* 重合處回到原色,岔開處留下彩色邊,也就是色散。
* - `rainbow`:整片染成純色再依距離換色相,最單純但也最平。
*
* @default 'gradient'
*/
colorMode?: EchoColorMode;
/**
* `gradient` 模式的色帶,至少兩個顏色,接受任何 CSS 色彩字串。
*
* 亮度零對應第一個顏色,全亮對應最後一個,中間線性內插。
*
* 色帶負責用亮度換色相,不該拿來換亮度,因此每個顏色的亮度要接近而且都夠高。
* 開頭若放了深色,深色文字查過去仍然是深色,糊開之後看起來就是一團黑。
*
* @default ['#FF6FB5', '#FF9F45', '#FFE85C', '#7CFFB2', '#6FD4FF']
*/
gradient?: string[];
/**
* `chromatic` 模式的通道錯開距離(px)。
*
* 調大彩色邊越寬,過大則三個通道各走各的,看不出原本的形狀。
*
* @default 4
*/
chromaticOffset?: number;
/**
* 預先算好的離屏畫布張數。
*
* `gradient` 與 `rainbow` 模式下是色階數,決定尾巴的顏色細緻度;
* `chromatic` 模式下則是方向階數,決定色散的方向能跟得多準。
*
* 不影響殘影張數。記憶體與這個值成正比,包很大一塊內容時建議調低。
*
* @default 8
*/
colorCount?: number;
/**
* 相鄰兩個色階的色相差(deg)。
*
* 360 除以色階數量可讓尾巴剛好繞完一圈色環,調小則整條尾巴偏同一個色系。
*
* @default 45
*/
hueStep?: number;
/**
* 走多遠換完一輪色階(px)。
*
* 顏色跟著走過的距離跑,而非跟著時間,因此甩得快慢不影響配色,
* 只有距離會。調小則色帶密,調大則整條尾巴同色系。
*
* @default 240
*/
hueCycleDistance?: number;
/**
* 上色強度,0 為完全保留原色、1 為整片換成指定的顏色。
*
* 直接旋轉原色的色相看似最自然,但白色卡片、灰階圖片轉不出顏色,
* 尾巴只會是一團灰,因此改成把顏色換掉。
* `chromatic` 模式用不到這個值,色散本來就從原色拆出來。
*
* `gradient` 模式建議留在接近 1,色帶本身已經帶著明暗層次,
* 混回原色只會把彩度稀釋掉。`rainbow` 模式則相反,1 會變成純色剪影。
*
* @default 0.95
*/
tint?: number;
/**
* 最舊那個色階的模糊半徑(px),中間各階依序遞增。
*
* 模糊在預先上色時就算完,每幀不必重跑,因此調大不影響移動時的效能,
* 只會讓離屏畫布往外多撐 `blur * 3` 的邊界。
*
* 調大會把同樣的墨量攤到更大的面積上,尾巴跟著變淡,
* 覺得看不清楚時這裡通常比 `opacity` 更有效。
*
* @default 6
*/
blur?: number;
/**
* 剛畫上去那一段的尾巴濃度。
*
* 這是疊完之後的濃度,不是單張殘影的透明度,
* 因此調整 `stampGap`、`trailDecay` 或移動速度,都不會讓尾巴跟著變濃或變淡。
*
* @default 0.85
*/
opacity?: number;
/**
* 殘影之間改用相加混合,密的地方會累加爆白成霓虹。
*
* 深色底才看得出效果,淺色底上相加只會整片洗白,
* 這時可搭配 `blendMode` 一起調整。
*
* @default false
*/
glow?: boolean;
/**
* 移動時的擠壓拉伸強度,0 為關閉。
*
* 借用動畫的 squash and stretch,沿運動方向拉長、垂直方向壓扁,
* 速度慢下來就彈回原形。速度先用阻尼平滑過,才不會每幀抖動。
*
* @default 0.35
*/
stretch?: number;
/**
* 畫布的解析度倍率,1 代表一個畫布像素對一個 CSS 像素。
*
* 畫布跟視窗一樣大,跟著裝置像素比走的話,
* 高解析螢幕每幀要重新處理的像素會多出四倍,這是整個元件最貴的一筆。
* 殘影本來就是模糊的裝飾,1 倍看不出差別,想要更銳利再往上調。
*
* @default 1
*/
resolution?: number;
/**
* 畫布疊在底下內容之上的混合模式。
*
* 深色底改用 `screen` 或 `plus-lighter`,殘影會像發光一樣浮起來。
*
* @default 'normal'
*/
blendMode?: EchoBlendMode;
/**
* 在主控台輸出效能統計,除錯用。
*
* 每兩秒彙整一次每幀各階段耗時、蓋章數與揮發面積,
* 離屏畫布重建時另外輸出建置成本,並在 DevTools 效能面板留下 User Timing 標記。
*
* @default false
*/
debug?: boolean;
}Methods
interface Expose {
/** 重新擷取內容並重建殘影,內容或主題變更後呼叫 */
refresh: () => Promise<void>;
/** 立刻抹掉畫布上所有殘影 */
clear: () => void;
}Slots
interface Slots {
/** 要留下殘影的內容 */
default?: () => unknown;
}