Rebuilding a Mouse-Reactive Type Field with Canvas and GSAP

Letters That Behave Like a Field

A mouse-reactive type field is a Canvas-rendered arrangement of glyphs whose positions respond continuously to pointer distance, then return elastically to fixed resting coordinates. Each letter has a home, a temporary displacement, and enough velocity to overshoot before settling.

This interaction belongs to the experimental interface language of the mid-2000s. Typography often served two jobs at once: readable material and an active motion surface. A headline could behave like navigation; a repeated word could become texture; a cursor could bend the composition without triggering a conventional button state. Work associated with an Exopolis digital creative agency sensibility sat comfortably in that overlap between graphic design and interface behavior.

The reconstruction below uses Canvas 2D, Pointer Events, ResizeObserver, media queries, and GSAP. It reproduces the period’s interaction grammar with current browser tools. It does not claim to recover source code from an archived production.

Letters That Behave Like a Field

The Useful Boundary

The field should feel immediate while leaving its typographic structure legible. An interaction radius between 100 and 150 CSS pixels is a practical working range; the finished preset uses 140 pixels. At a 60fps baseline, the entire frame has about 16.6 milliseconds available, so vector calculations and drawing need to remain compact.

One Canvas, Fixed Glyph Homes

Start with three layers of responsibility: HTML carries the meaningful words, CSS owns the visible area, and JavaScript measures that area before constructing the grid. The drawing code should never decide page layout.

A minimal document needs one wrapper and one canvas. If the repeated letters communicate actual copy, preserve that copy as visible text or a visually hidden semantic equivalent. Decorative repetition can use an accessible label that states the phrase once rather than forcing a screen reader through every glyph.

Why the Glyphs Share a Drawing Surface

An early DOM approach can look attractive because every character accepts a CSS transform. It also creates a costly coordination problem. Profiling becomes uncomfortable beyond roughly 400 independently animated glyphs, where style work and layout thrashing begin to consume the frame. A single Canvas 2D context can carry a field of more than 1,200 glyphs while keeping the drawing pass within a roughly 12-millisecond working budget.

Canvas Cutover

Use individual DOM letters when each glyph needs focus, selection, or semantic identity. Use one canvas when the letters operate as a dense visual field.

Scale the backing store by devicePixelRatio, capped in the final preset at 2, while retaining CSS pixels for every home coordinate, pointer coordinate, and velocity. The context transform handles physical pixels. The physics remains readable, and the type avoids the softness produced by an undersized backing store.

Each glyph record needs only a character, homeX, homeY, current x and y, plus vx and vy. That explicit state makes resize behavior and spring integration easy to inspect.

From Pointer Radius to Typographic Force

Pointer coordinates arrive in viewport space. Glyph coordinates live inside the canvas. Convert between them immediately:

  1. Read clientX and clientY from the Pointer Event.
  2. Read the canvas rectangle with getBoundingClientRect().
  3. Subtract the rectangle’s left and top edges.
  4. Store the result in CSS pixels.

For every glyph, calculate the vector running from the pointer to the current glyph position. Its length is the distance. Dividing that distance by the interaction radius produces a normalized value; subtracting the clamped result from one makes the influence strongest at the pointer and zero at the boundary.

Soften the Edge Before Applying Force

A linear influence exposes the radius as a visible ring. Letters suddenly join or leave the effect as the cursor crosses that boundary. A smoothstep curve removes the hard derivative, and an exponent controls the character of the interior response.

An exponent of 2.4 produces the softer, magnetic behavior used here. A value around 0.8 creates a sharper, more rigid repulsion. The first suits a continuous field because the cursor opens a pocket in the typography rather than punching a crisp hole through it.

const ratio = Math.min(distance / radius, 1); const edge = 1 - ratio; const smooth = edge * edge * (3 - 2 * edge); const influence = Math.pow(smooth, 2.4);

Guard the zero-distance case before normalizing the vector. A pointer can land directly on a glyph coordinate, and division by zero will contaminate the position state with NaN.

A Visible Spring on the GSAP Clock

gsap.ticker should be the field’s only animation clock. Its job is scheduling. The spring remains explicit in the update loop so the relationship among restoring force, cursor force, velocity, and damping stays available for tuning.

The official gsap.ticker documentation describes the callback timing interface. For this field, convert its elapsed milliseconds to seconds and clamp unusually long intervals at 33 milliseconds. Without that ceiling, returning from a backgrounded tab can feed a large physics step into every glyph at once.

The Per-Frame Order

  1. Calculate acceleration from the glyph toward its home coordinate.
  2. Add cursor repulsion when the pointer is active and inside the radius.
  3. Integrate acceleration into velocity.
  4. Apply exponential damping based on elapsed time.
  5. Integrate velocity into position.
  6. Clear the canvas and redraw all glyphs.

A baseline spring coefficient of 45 gives the recovery enough authority to preserve the grid. Exponential damping using Math.exp(-10 * dt) behaves consistently across ordinary frame-rate variation, unlike subtracting a fixed amount of velocity on every tick.

The order matters. Damping before acceleration weakens newly applied forces. Drawing before integration introduces a one-frame lag. Keeping one clock also avoids the subtle drift that appears when pointer smoothing and glyph recovery run in separate animation loops.

Resize, Touch, and Reduced-Motion Boundaries

Observe the wrapper rather than the canvas. The wrapper owns layout; the canvas merely follows its measured dimensions. This distinction prevents the drawing surface from changing its own observed size and repeatedly triggering ResizeObserver.

Wrapper Owns Size

An absolutely positioned canvas still needs a wrapper with a defined block size. Without that boundary, backing-store updates can provoke an infinite resize loop.

When the wrapper changes, resize the backing store, reapply the density transform, and rebuild the home coordinates. A 150-to-250-millisecond debounce window can reduce repeated grid construction during sustained layout changes. The animation itself should continue on the ticker.

Choose a Resize Policy Deliberately

The cleanest policy resets each glyph directly onto the rebuilt grid. It avoids mismatched records when the number of columns changes. A more continuous treatment remaps current positions proportionally into the new width and height, then lets the spring finish the transition. That approach preserves motion but requires careful handling when rows appear or disappear.

Fine pointers may activate the field on hover. Coarse pointers should activate only while pressed. Capture the pointer on contact, release it on pointerup or pointercancel, and avoid calling preventDefault(); ordinary page scrolling must remain available outside an intentional canvas gesture.

When prefers-reduced-motion: reduce matches, remove the ticker and draw the resting grid once. This suspension boundary is especially important for dense animated lettering because nearly every visible mark otherwise remains in motion.

The 48-Pixel Exopolis Preset

This preset builds a full-width dark field from repeated uppercase EXOPOLIS glyphs. Grid spacing follows a 48-pixel font, pointer influence extends 140 CSS pixels, and pixel density stops at 2. The spring coefficient is 45, damping uses a factor of 10 with delta time, and each physics step is capped at 33 milliseconds.

The 48-Pixel Exopolis Preset

Copy the Markup and Layout Contract

<div id="type-wrap"> <canvas id="type-field" aria-label="Repeating EXOPOLIS letter field"> EXOPOLIS repeated as a decorative interactive letter field </canvas> </div> #type-wrap { inline-size: 100%; block-size: var(--type-field-height); background: black; } #type-field { display: block; inline-size: 100%; block-size: 100%; }

Attach the Complete Field Logic

const wrap = document.querySelector('#type-wrap'); const canvas = document.querySelector('#type-field'); const ctx = canvas.getContext('2d'); const reduced = matchMedia('(prefers-reduced-motion: reduce)'); const coarse = matchMedia('(pointer: coarse)'); const fontSize = 48; const radius = 140; const spring = 45; const text = 'EXOPOLIS '; let glyphs = []; let width = 0; let height = 0; let running = false; const pointer = { x: 0, y: 0, active: false }; function measure() { const rect = wrap.getBoundingClientRect(); const dpr = Math.min(devicePixelRatio || 1, 2); width = rect.width; height = rect.height; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); buildGlyphs(); draw(); } function buildGlyphs() { glyphs = []; let index = 0; for (let y = fontSize; y < height; y += fontSize) { for (let x = fontSize / 2; x < width; x += fontSize) { glyphs.push({ char: text[index++ % text.length], homeX: x, homeY: y, x, y, vx: 0, vy: 0 }); } } } function locate(event) { const rect = canvas.getBoundingClientRect(); pointer.x = event.clientX - rect.left; pointer.y = event.clientY - rect.top; } canvas.addEventListener('pointermove', event => { if (coarse.matches &&!pointer.active) return; locate(event); pointer.active = true; }); canvas.addEventListener('pointerenter', event => { if (!coarse.matches) { locate(event); pointer.active = true; } }); canvas.addEventListener('pointerleave', () => { if (!coarse.matches) pointer.active = false; }); canvas.addEventListener('pointerdown', event => { if (!coarse.matches) return; canvas.setPointerCapture(event.pointerId); locate(event); pointer.active = true; }); function release(event) { pointer.active = false; if (canvas.hasPointerCapture(event.pointerId)) { canvas.releasePointerCapture(event.pointerId); } } canvas.addEventListener('pointerup', release); canvas.addEventListener('pointercancel', release); function tick(time, deltaMs) { const dt = Math.min(deltaMs, 33) / 1000; const damping = Math.exp(-10 * dt); for (const glyph of glyphs) { let ax = (glyph.homeX - glyph.x) * spring; let ay = (glyph.homeY - glyph.y) * spring; if (pointer.active) { const dx = glyph.x - pointer.x; const dy = glyph.y - pointer.y; const distance = Math.hypot(dx, dy); if (distance > 0 && distance < radius) { const edge = 1 - distance / radius; const smooth = edge * edge * (3 - 2 * edge); const influence = Math.pow(smooth, 2.4); const force = influence * radius * spring; ax += (dx / distance) * force; ay += (dy / distance) * force; } } glyph.vx = (glyph.vx + ax * dt) * damping; glyph.vy = (glyph.vy + ay * dt) * damping; glyph.x += glyph.vx * dt; glyph.y += glyph.vy * dt; } draw(); } function draw() { ctx.clearRect(0, 0, width, height); ctx.fillStyle = 'white'; ctx.font = `${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const glyph of glyphs) { ctx.fillText(glyph.char, glyph.x, glyph.y); } } function syncMotion() { if (reduced.matches && running) { gsap.ticker.remove(tick); running = false; buildGlyphs(); draw(); } else if (!reduced.matches &&!running) { gsap.ticker.add(tick); running = true; } } new ResizeObserver(measure).observe(wrap); reduced.addEventListener('change', syncMotion); measure(); syncMotion();

Set --type-field-height in the page layout, load GSAP, and paste the three blocks in order. The wrapper establishes the full-width dark stage; measurement creates the 48-pixel grid; Pointer Events open a 140-pixel pocket in the letters; the ticker pulls every displaced glyph back to its recorded home.

Cookie preferences