Skip to content

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

An echo has to look like the content, and the cheapest way there is a photo. snapdom turns the slot into a canvas and every echo copies from it. The original DOM stays visible and interactive; the echoes are decoration painted behind it.

A captured canvas does not reliably carry the CSS corner radius, and a circular avatar easily comes back square. So the capture gets trimmed against the element's own outline: an even-odd path fills the slivers left by subtracting the rounded rect from the square one, and destination-out erases them. Corners with a painted background underneath are skipped, since erasing those would punch holes in a card.

An echo is where the element used to be

While the page scrolls the element never moves inside the document; the viewport does. So the component records past viewport coordinates, subtracts the current ones, and draws at the difference.

window.scrollY only knows about page scrolling. Reading getBoundingClientRect() once per frame also covers scrollable containers, sticky, and elements animating on their own. What gets measured is the undeformed outer layer plus the drag offset, because measuring the deformed element folds the squash into the coordinates and makes the trail jitter.

The canvas is position: fixed across the viewport. Fixed elements take no space, so no scrollbar appears, an ancestor's overflow: hidden cannot clip them, and those coordinates become drawing coordinates untouched.

Let it linger and evaporate

The canvas is never wiped. It only fades a little each frame, which turns scrolling and dragging into light painting. The fade uses destination-out to subtract alpha directly; a translucent rectangle would tint the whole trail and break on the opposite background. Old echoes stay put, so each frame only adds the short segment just travelled.

How large an area the fade covers decides whether frames drop. The first version filled the union of everything ever painted, a region that only grows and soon equals the whole viewport. Now each frame's painted region carries a timestamp and only the ones inside trailDecay get unioned, which shrinks the fill to the trail itself.

Scrolling out of view pauses the requestAnimationFrame loop, and both the canvas and the last position are cleared on return. Otherwise the displacement piled up while away lands as one straight line across the page.

Stamp along the path at a fixed distance

One echo per frame is the obvious approach, and it is where the trail breaks into blobs: the count stays fixed, so faster movement widens the gaps. Deriving the count from distance keeps it continuous. Neighbours stay stampGap apart, a longer step gets more stamps, and maxStampCount caps the total before the gap widens again.

Stamping densely means a spot gets covered over several consecutive frames, and using the target opacity on every stamp piles them into a solid slab. Stacking n layers of alpha a yields 1 - (1 - a)^n, so solving for a gives the per-stamp value.

That formula misses one thing: the echoes evaporate while they stack. They land across many frames, so by the time the last one arrives the first ones have faded. Treating them as simultaneous overestimates badly, over-compensates, and leaves a trail too faint to read, with the gap widening the slower the movement. Crossing its own length takes extent / speed, and integrating the decay over that span yields a factor below one that scales the layer count down to what actually still stacks.

Replacing the color

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

Gradient map is the default. Luminance indexes a 256-step ramp, bright values landing at the end and dark ones at the start. A flat tint paints one color and the shading disappears; a gradient map re-maps those values onto the ramp, so a photo keeps its depth and a card keeps its shadows. Mapping supplies enough saturation that each step only needs hue-rotate(). The ramp trades luminance for hue and should not carry luminance of its own, so every stop needs a similar, high brightness; a dark stop at the start leaves dark text dark.

Chromatic aberration splits the echo into red, green and blue single-channel canvases, offset along the direction of travel and added back. Aligned channels restore the original color and only the offsets leave fringes. That direction is known at runtime, so the mode bakes one canvas per direction bucket and looks the current one up, keeping a single drawImage per stamp.

Flat tint lays a solid color with source-atop, painting only where pixels already exist, with hue advancing by distance travelled.

The first two read pixels, and a cross-origin image taints the canvas so getImageData throws; both fall back to the original colors or a plain blurred echo. With glow on, echoes combine with lighter and dense areas blow out into neon, so the compensation switches to dividing by the overlap count.

Grab it, fling it, watch it stretch

Scrolling alone makes the interaction passive: people scroll to read and the trail is a by-product. Being able to grab it hands the stroke to the user.

Pointer-down records the origin and the gesture counts as a drag past four pixels, so a plain click still gets through. pointermove is bound to window so a finger leaving the element is still tracked; Pointer Capture would track it too, but it takes over hit testing from the press and text inside could not even be selected. Release runs a spring-damper, acceleration -k * offset - c * velocity, integrated back to the origin.

Holding the pointer down must not highlight anything, so selectstart is cancelled and the whole document disables selection for the duration. Browsers also drag images and links natively, so dragstart is cancelled too, otherwise native drag-and-drop hijacks the gesture. Touch only claims the horizontal axis and leaves vertical scrolling alone.

While moving, the element stretches along its direction of travel and squashes across it, the classic squash and stretch. Any angle has to squash correctly, so it rotates into the direction, scales, and rotates back.

The anchor never sits at the center: scaling from there grows both ends outward and reads as the whole block breathing. Anchored at the leading edge the front holds still while the body stretches behind, which is what being dragged looks like. During a drag the anchor moves to the grab point so the pixel under the finger stays there. Per-frame velocity is noisy, so it passes through a damping filter that also leaves a little follow-through.

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

Methods

interface Expose {
  /** 重新擷取內容並重建殘影,內容或主題變更後呼叫 */
  refresh: () => Promise<void>;
  /** 立刻抹掉畫布上所有殘影 */
  clear: () => void;
}

Slots

interface Slots {
  /** 要留下殘影的內容 */
  default?: () => unknown;
}

v0.81.0