Skip to content

Three VitePress Customization Tricks ​

The default VitePress theme works great — and looks exactly like every other VitePress blog out there.

This post records three small visual effects I added to this blog: a particle background, a cursor glow, and an ambient gradient. None of them touch the theme source; one effects.js plus a chunk of CSS holds everything. The effects themselves are cheap. What's worth writing down are the traps behind each one.

First: how to mount them without fighting the theme ​

All the effects live in a single effects.js, wired in with one line:

ts
// docs/.vitepress/theme/index.ts
setup() {
  onMounted(async () => {
    await import('./effects.js')
  })
}

Two deliberate choices:

  • Dynamic import(): the effect code stays out of the initial bundle and loads after onMounted, so it never competes with first render.
  • One shared container: every effect element goes into a single fullscreen #effects-layer with pointer-events: none — the effects are painted on the page, and can never block clicks or text selection.

Trick one: the particle background is just one canvas ​

The effect: small dots drifting across a fullscreen canvas, nearby ones connected by lines, particles near your mouse getting gently pulled toward it. The classic particles effect.

The skeleton is simple: make a fullscreen canvas, scatter some particles, update and redraw every frame.

The trap is in the coordinates. I originally mixed physical pixels and CSS pixels: the canvas backing store sized in physical pixels, particle positions in CSS pixels, mouse coordinates in yet another unit. On a standard display it happened to work; on a high-DPI screen the particles shrank and huddled in the top-left corner, and it took me a while to realize the units simply didn't agree.

The fix: the whole system speaks one coordinate language (CSS pixels), and the physical-pixel conversion collapses into a single place:

js
const dpr = Math.min(window.devicePixelRatio || 1, 2) // cap at 2 so 4K screens don't inflate the canvas

function resize() {
  vw = window.innerWidth          // every coordinate from here on is a CSS pixel
  vh = window.innerHeight
  canvas.style.width = vw + 'px'
  canvas.style.height = vh + 'px'
  canvas.width  = Math.floor(vw * dpr)   // physical backing resolution
  canvas.height = Math.floor(vh * dpr)
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // the conversion happens only here
}

After setTransform, everything keeps drawing in CSS pixels and the scaling is the transform matrix's problem. Crisp on retina, and not a single stray * dpr left in the logic.

The second trap is the connecting lines. The naive approach measures every pair of particles — 80 particles means 6,400 distance checks per frame. Tolerable, but mostly wasted work: particles more than 120px apart don't need to know about each other. Split the screen into a 120px grid and only compare particles within the same or neighboring cells; the cost drops by an order of magnitude.

Then a few house rules about not running animation nobody can see:

js
// skip drawing while the tab is in the background
if (!isPageVisible) { requestAnimationFrame(animate); return }

// particle count follows screen area — don't hardcode it
const count = Math.min(80, Math.floor(innerWidth * innerHeight / 15000))

Plus: users with prefers-reduced-motion never get it initialized, and mobile skips it entirely (see the checklist below). That's the particle system, stable.

Trick two: the cursor glow — visuals in CSS, JS only eases ​

The effect: a soft orb of light trailing the mouse, slightly late, as if the light were thrown behind the cursor.

The right division of labor here: all the visuals live in CSS, and JS does exactly one thing.

css
.cursor-glow {
  position: fixed;
  width: 500px;
  height: 500px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(108, 92, 231, 0.2), transparent 70%);
  mix-blend-mode: screen;   /* brightening blend for the dark theme */
  filter: blur(25px);
  pointer-events: none;
  will-change: transform, opacity;
}

On the JS side it's one easing line per frame:

js
glowX += (mouseX - glowX) * 0.08
glow.style.transform = `translate(${glowX}px, ${glowY}px) translate(-50%, -50%)`

Moving 8% toward the target each frame is all the "trailing glow" is. Smaller coefficient, longer trail.

Three details worth the text:

DetailWhy
Move with transform, not left/topleft/top triggers layout reflow every frame; transform runs on the compositor, on the GPU
Theme-aware mix-blend-modescreen to brighten on dark, multiply on light — otherwise the glow turns muddy gray on white
Fade opacity to 0 on mouseleaveOtherwise a blob of light freezes at the edge of the screen

On mobile it's display: none — on a device with no mouse, the effect means nothing.

Trick three: the ambient gradient, and why I made it static ​

The counterexample in this section is me.

The first version of the background moved: a fullscreen element animating background-position, layered with a hue-rotate spin for a slow "flowing" mood. Once it ran, two problems showed up:

  1. The fullscreen element repainted every frame — GPU and battery paying continuously for an effect almost nobody would notice;
  2. hue-rotate periodically dragged the brand purple into green, into blue — the entire color discipline gone.

So I made it static: two radial-gradient blobs, one purple, one pink, opacity dialed down to 0.15 and 0.1, sitting at the very bottom of the page as atmosphere.

css
.bg-gradient {
  background:
    radial-gradient(ellipse 80% 50% at 20% 40%, rgba(108, 92, 231, 0.15), transparent),
    radial-gradient(ellipse 60% 40% at 80% 60%, rgba(253, 121, 168, 0.1), transparent);
}

Zero per-frame cost. The moving parts are the particles and the glow; the static gradient just sets the floor. Atmosphere comes from layering, not from motion.

Sometimes a good performance optimization is deleting an animation.

The checklist ​

All three effects together are under 250 lines. To wrap up:

EffectPerformance notesMobile
Particle backgroundDPR conversion in one place (setTransform), grid-accelerated lines, paused in background tabsOff
Cursor glowtransform compositor layer, eased follow, pointer-events: noneOff
Ambient gradientStatic, zero per-frame costOn

Before adding any of this, ask: on what device, at what cost, for whom? The animation you can't answer that for — not building it is the right call.

Last Updated: