Pause what you cannot see: sleeping loops offscreen and behind overlays
A lot moves on this site: the hero field, the rings, the scrolling word band, the cover art in the menu. In this note I write down the rules they run by: transform and opacity only, rAF loops that bypass Vue, and small mechanisms that stop anything you cannot see.
01The rule: transform and opacity only
My first rule for animation: whatever changes per frame should be transform or opacity. These two properties can usually be handled on the compositor without triggering layout or paint. That is why the scroll-linked reveals in base.css (rv1, rv2, rv3) use only translate, scale, rotate and opacity. They run entirely in CSS with animation-timeline: view(); browsers without support simply show the content.
The same rule holds in small places. The crosshair lines in the hero move with translateX and translateY rather than left and top. The hero's size is measured once, cached, and reset only on resize or scroll, so no layout is read while things move.
Scrolling needs the same care. The scroll listener in usePointer does not do work on every event; it asks for a frame and runs in that frame. Writing scroll progress to a :root variable would have been easy, but as the comment in the code says, that would restyle the whole page. Instead the value goes to just two consumers: the progress bar in the header moves with scaleX, the hero rings with translate. The rings' parallax is not updated past the first 1200 pixels, or at all with reduced motion on.
02Keeping Vue out of the loop
The hero on the home page is a 14×8 field of crosses and five rings; they follow the pointer, and a click sends a pulse through the field. Had I written this with reactive state, every pointermove would make Vue re-render 112 cells. Instead the field is rendered once. pointermove only updates two numbers and calls schedule, which asks for a requestAnimationFrame only if no frame is already pending.
function onMove(e: PointerEvent) {
const p = local(e)
px = p.x
py = p.y
schedule()
}
function schedule() {
if (!raf) raf = requestAnimationFrame(paint)
}
// inside paint(), for each of the 112 cells
const still = k === 0 && w === 0
if (still && c.rest) continue // far from pointer and already drawn at rest: nothing to write
c.rest = still
// end of paint(): only a running pulse asks for another frame
if (pulse) schedule()Inside paint, each cell gets its distance to the pointer and, if there is one, the effect of the pulse. If both are zero and the cell was already drawn at rest, continue skips it and nothing is written to the DOM. The loop also stops itself: it asks for another frame only while a pulse is running. When the pointer stops, no frame is requested, so almost nothing runs while idle.
GenerativeField in the playground uses the same pattern: 144 bars, angles rounded to 3 degree steps, and style left untouched when the value matches the previous frame. For pointer work on other pages there is useRafThrottle in src/composables/useRaf.ts; it collapses rapid events into one update per frame.
03Pausing what is offscreen
CSS animations keep running even when nobody can see them. For that I wrote a directive called v-pause-offscreen (src/composables/useOffscreen.ts). There is one shared IntersectionObserver for the page; when an observed element leaves the viewport it gets the off class. A single rule in base.css does the rest.
let io: IntersectionObserver | null = null
function observer() {
if (!io) {
io = new IntersectionObserver(
(entries) => {
for (const e of entries) (e.target as HTMLElement).classList.toggle('off', !e.isIntersecting)
},
{ rootMargin: '120px 0px' }
)
}
return io
}
/* base.css */
.off, .off * { animation-play-state: paused !important; }Because rootMargin is 120 pixels, loops restart a little before they enter the viewport; you never see a frozen frame while scrolling. The directive is used on the hero rings, the spinning drawings in ProceduralArt, and the OrbitRings and ReactiveGrid experiments. The JavaScript-driven word band, MarqueeBand, applies the same idea with its own IntersectionObserver: when the band is not visible, the loop does not ask for another frame.
04Pausing what is behind an overlay
While the menu or the command palette is open, the page sits behind it, but its loops could keep running. The ui store has a computed called overlay: true when the menu or the palette is open. usePointer watches it and puts the ovl class on the html element; in shell.css the spinning and pulsing classes under .ovl (spin, horb, orr, rpA, rpB) are paused. MarqueeBand reads overlay directly; when it is true the loop lets go, and when it turns false a watch starts it again.
Running an animation nobody can see helps no one; stopping it is usually a single class.
05Not rendering at all
For some things, creating them late is cheaper than pausing them. The five cover drawings in the menu (about 200 shapes, according to the comment in the code) do not enter the DOM until the menu is opened for the first time; a ref called seen controls that with v-if. The lower sections of the home page, the footer and the closing sections of long pages use content-visibility: auto, so sections far from the viewport skip layout and paint. contain-intrinsic-size: auto 900px remembers a section's real height once it has rendered, so the scrollbar does not jump.
Fonts do not hold up the first paint either. In index.html the Google Fonts stylesheet is preloaded first, then loaded with media="print" and switched to all on load; display=swap shows text right away. If JavaScript is off, the plain link inside noscript takes over.
06Reduced motion
With prefers-reduced-motion on, some of these loops never start. useReducedMotion keeps a single matchMedia listener. MarqueeBand checks isReduced before starting, the hero creates no pulse on click and skips its entrance animation, and responsive.css turns off the spinning animations and the reveals. A loop that never runs is cheaper than one that is paused.