Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

Tracers Wrapper wrapper ​

Fling it or scroll past it, and the wrapped content leaves a rainbow trail across the screen. The marks do not vanish at once; they stay where they were painted and evaporate over a few seconds.

Tech Keywords ​

NameDescription
DOM to ImageConverts DOM elements into images, based on SVG foreignObject
Canvas 2D APIBasic 2D drawing API that efficiently renders graphics too complex for the DOM
Canvas FilterApplies CSS filters such as blur and hue rotation directly on a canvas
Canvas Composite OperationControls how new drawings combine with what the canvas already holds
CSS mix-blend-modeSets how an element blends with what is painted beneath it, such as additive plus-lighter or darkening multiply
Element getBoundingClientRect()Gets an element position and size relative to the viewport
Pointer EventsDetects pointer movement, clicks, hovers, and more, providing coordinates and target information
Spring-Damper SystemSimulates spring oscillation and rebound with stiffness and damping constants, commonly used for natural UI motion feedback
IntersectionObserverDetects when elements enter or leave the viewport

Examples ​

Basic Usage ​

Just wrap the content. Scrolling leaves a trail, flinging it leaves a much bigger one, and letting go springs it back into place.

Codfish
A fish who loves programming, but has no fingers to type on a keyboard, and can't even buy a computer that works underwater.
View example source
vue
<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>

How It Works ​

Photograph it first ​

snapdom captures the slot into a canvas and every echo copies from it, with the original DOM staying visible and interactive. Capturing has two pitfalls. snapdom multiplies by the device pixel ratio by default, while the canvas itself only ever gets drawn at 1x, so the canvas ends up a dozen times larger than necessary and per-pixel tinting plus stamp sampling grow correspondingly pricier — dpr is pinned to 1 to avoid that. Capturing also needs the element's inline style rewritten beforehand, and that write is itself a mutation: the MutationObserver sees it and fires another capture, forever, unless the observer's own records are consumed the instant the write completes.

A captured canvas does not reliably carry the CSS corner radius, so after capture the four corners outside the element's rounded outline are cut away with destination-out.

An echo is where the element used to be ​

Each frame records the element's viewport coordinates with getBoundingClientRect(); subtracting the previous frame's gives the echo's displacement, which keeps working through container scrolling, sticky, and the element animating on its own. The canvas is position: fixed across the viewport — it takes no space, isn't clipped by an ancestor, and its viewport coordinates can be used as drawing coordinates directly.

A page usually holds more than one wrapper, and how the measuring is written decides whether cost multiplies with instance count. If each instance interleaves its own read-then-write, every later instance's read has to wait for a forced layout recalculation. So all instances on a page share one requestAnimationFrame: everyone reads first, then everyone writes, and layout only recalculates once per frame. Once an element is still and its canvas is clean, the only work left each frame is checking whether the position changed, so measuring throttles down to every third frame, then every twelfth after five seconds of stillness. Scrolling or a pointer-down wakes it immediately — the throttling only exists as a fallback for cases like a pure CSS animation that has no event to hook into.

Let it linger and evaporate ​

The canvas is never wiped; each frame subtracts a little alpha with destination-out, which turns scrolling and dragging into light painting. Laying a translucent rectangle over it would tint the whole trail and break on a different background. The fade only fills the regions still inside trailDecay, otherwise the fill area grows to the entire viewport and frames drop.

A canvas the size of the viewport costs several MB on its own, yet sits fully transparent most of the time, so it's only allocated when there's something to draw — once the trail fades out or the element scrolls off screen, the whole block of pixels is freed. Coming back also clears the old coordinates, otherwise the displacement accumulated while away would land as one straight line across the page on the first frame.

Stamp along the path at a fixed distance ​

One stamp per frame is the obvious approach, and it's where the trail breaks into a row of separate blobs: the count stays fixed, so faster movement widens the gaps. Deriving the count from distance instead keeps it continuous — neighbouring echoes stay stampGap apart, with maxStampCount as the ceiling. At extreme speeds a single frame's fill can exceed the whole canvas, and larger elements suffer more, so half the canvas area is used as a budget on top of that: going over it widens the gap automatically. At that speed the trail is already long and faint, so a few missing stamps go unnoticed.

Stamping densely piles echoes into a solid slab. Stacking n layers of alpha a yields 1 - (1 - a)^n, so solving for a gives the per-stamp value. The echoes keep evaporating while they stack, though — the earliest stamps have already faded by the time the last one lands — so the layer count gets multiplied by the decay factor over the time it takes the element to cross its own length, or the compensation overshoots.

Replacing the color ​

White cards and grayscale photos have no saturation, so rotating the hue still leaves a gray trail — the color has to be replaced outright. colorMode offers three ways:

  • Gradient map (default): luminance indexes a 256-step ramp, keeping the shading, then hue-rotate() shifts the hue. Every step on the ramp needs a similar, high brightness, otherwise dark text stays dark after the lookup.
  • Chromatic aberration: splits into red, green and blue canvases, offset along the direction of travel and added back, leaving color fringes only at the edges. Directions are pre-baked into buckets and looked up when drawing.
  • Flat tint: layers a solid color with source-atop, with hue advancing by distance travelled.

The first two read pixels, and a cross-origin image taints the canvas, falling back to the original colors. With glow on, echoes combine with lighter instead, and dense areas blow out into neon.

Grab it, fling it, watch it stretch ​

A press only counts as a drag past four pixels of movement, so a plain click still fires normally. pointermove is bound to window so a finger leaving the element is still tracked; Pointer Capture isn't used because it makes text unselectable. Release runs a spring-damper back to the origin. selectstart and dragstart are both blocked for the duration of the drag, and touch only claims the horizontal axis, leaving vertical scrolling to the page.

While moving, the element stretches along its direction of travel and squashes across it — the classic squash and stretch. It rotates into the direction of travel, scales, then rotates back, so any angle works correctly.

The anchor sits at the leading edge of the direction of travel — the front holds still while the body stretches behind, which is what being dragged looks like; anchoring at the center would read as the whole block breathing. During a drag the anchor moves to the grab point so the spot under the finger doesn't slide. Velocity passes through a damping filter first, which also leaves a bit of follow-through once the hand stops.

Source Code ​

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;
}

v0.90.7