七彩殘影包裝器 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 照常顯示、照常互動,殘影只是疊在後面的裝飾。
拍回來的畫布不保證帶著 CSS 圓角,圓形頭像很容易拍成方形照片。因此擷取後會照元素外框補裁一次,用 even-odd 填出方角減圓角的那幾塊,再以 destination-out 挖掉。圓角底下若有人畫背景,那圈像素本來就該露出背景,這種就跳過不挖,否則會在卡片上開洞。
殘影是元素過去的位置
捲動時元素在文件裡沒有移動,移動的是視窗。殘影要留在原地,所以記下元素過去的視窗座標,跟目前的一減,差值就是該畫的位移。
讀 window.scrollY 只認得整頁捲動。改成每幀讀一次 getBoundingClientRect(),容器捲動、sticky、元素自己跑動畫,通通都留得下殘影。量的是還沒變形的外層,再自己加上拖曳位移,直接量變形後的元素,擠壓會混進座標讓殘影發抖。
畫布用 position: fixed 鋪滿視窗。固定定位不佔版面,撐不出捲軸,也不受祖先 overflow: hidden 裁切,getBoundingClientRect() 拿到的座標還能直接當繪圖座標。
留著慢慢揮發
畫布不會每幀清空,改成每幀淡一點,捲動與拖曳於是變成在頁面上光繪。淡化用 destination-out 扣掉既有像素的 alpha,蓋一層半透明色塊會把殘影整片染色,換個深淺背景就破功。舊殘影自己留在畫布上,每幀只需要補上新走的那一小段,不必重畫整條尾巴。
揮發要填多大一塊,直接決定會不會掉幀。第一版拿所有上過色的範圍去填,那塊只增不減,捲一陣子就等於整個視窗。改成記下每幀上色的區塊與時間,只聯集還在 trailDecay 之內的那幾塊,填色範圍就縮成尾巴本身。
元素捲出畫面就停掉 requestAnimationFrame,回來時清掉畫布與上一次的座標,否則離開期間的位移會在第一幀畫成一條橫跨整頁的直線。
沿路徑等距蓋章
一幀蓋一張最直覺,尾巴斷成一格一格也出在這裡。張數固定,移動越快間距拉得越開,看起來像一排印章。改以距離決定張數就連得起來,相鄰殘影固定相隔 stampGap,這一幀走得越遠就沿路多蓋幾張,maxStampCount 當煞車,到頂之後間距才放寬。
蓋得密,同一個位置連續好幾幀都會再蓋上一張,單張直接用目標濃度會疊成一片實心色塊。n 層透明度 a 疊起來得到 1 - (1 - a)^n,反解回去就知道單張該用多淡。
這個算法還漏了一件事,疊加期間殘影一直在揮發。這幾張分散在好幾幀,等最後一張蓋上去,最早那幾張早就淡掉了。把層數當成同時存在會嚴重高估,補償過頭,尾巴就淡到看不清楚,移動越慢差距越大。元素走完自己身長需要 身長 / 速度 這麼久,這段期間的衰減積分得到小於一的係數,乘回層數才是實際還疊得起來的張數。
換掉顏色
白色卡片、灰階照片本身沒有彩度,色相轉一圈還是灰的,所以顏色要整個換掉。colorMode 有三種換法。
漸層映射是預設,用亮度查一張 256 階色帶,亮的走尾端、暗的走開頭。平塗把整塊蓋成同一個顏色,明暗跟著消失;查表則把原本的明暗重新對應到色帶上,照片的立體感、卡片的陰影都留得下來。映射完彩度已經夠,各色階直接用 hue-rotate() 旋轉。色帶負責用亮度換色相,不該拿來換亮度,所以每個顏色的亮度要接近而且都夠高,開頭放了深色的話,深色文字查過去仍然是深色。
RGB 色散把殘影拆成紅、綠、藍三張單通道畫布,沿運動方向錯開再相加。三個通道相加會還原成原色,只有錯開處留下彩色邊。錯開方向要跟著運動方向跑,方向卻是執行時才知道,因此改以方向分階,每一階先烘好一張,畫的時候查表,維持一次 drawImage。
純色平塗用 source-atop 疊一層純色,只塗在已經有像素的地方,色相依走過的距離換。
前兩種都要讀像素,跨網域圖片會汙染畫布讓 getImageData 丟例外,這時退回原色或單純的模糊殘影。glow 打開之後殘影改用 lighter 相加,密的地方爆白成霓虹,濃度補償也換成除以重疊層數。
抓著甩,跟著變形
只能捲動的話,互動就很被動,使用者為了讀內容才捲,殘影只是副產品。抓得起來就不一樣,軌跡完全由手決定。
按下先記起點,位移超過四像素才算拖曳,單純點擊不會被吃掉。pointermove 掛在 window 上,手指跑出元素也追得到;換成 Pointer Capture 也追得到,但那會從按下就接管命中測試,包在裡面的文字連選都選不起來。放手後跑彈簧阻尼,加速度是 -k * 位移 - c * 速度,逐幀積分回原位。
按著不放不能出現反白,所以直接擋掉 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;
}Methods
interface Expose {
/** 重新擷取內容並重建殘影,內容或主題變更後呼叫 */
refresh: () => Promise<void>;
/** 立刻抹掉畫布上所有殘影 */
clear: () => void;
}Slots
interface Slots {
/** 要留下殘影的內容 */
default?: () => unknown;
}