let something

TL;DR go check out the demo!

It's an odd thing, starting a blog. The appeal of the available inspiration is partly in the texture of a site that holds a wealth of information: the long list of links, typographic variation, tags and feeds and all the accoutrements of an artifact showcasing years of work.

When you start, you might have a single, sad little morsel of a post. So you might as well put that sad little morsel in a bowtie and a top hat.

The goal

Make a hook for this blog’s homepage—something I hadn’t seen before, something fun and loud but (relatively) unobtrusive.

I settled on the idea of each link being a sort of bubble. The homepage would appear as a pretty standard grid of posts, but moving the cursor over one of those posts would cause it to subtly bounce around as though it was contained inside some springy, tactile container.

I say I settled on it. Really I thought it would be fun and easy to try out the basic version, and from there I got extremely carried away.

The animation

The seed of the idea is dead simple:

  • As the cursor moves through a shape, apply a force to that shape along the vector of the cursor movement, producing an offset as a result of its new velocity
  • Model a spring force on that offset, pulling the object back into its original position with a force proportional to the magnitude of the offset

But keep in mind, I wanted the effect to be ((relatively)) unobtrusive. If the link were to bounce around with every tiny flick of the cursor, it would be too distracting to use at all.

I wanted to apply this effect just to the border, but that involves collision detection which I would frankly love to be able to accomplish on my own. On each mouse movement, each bubble needs to detect whether the movement vector intersects an arbitrarily rounded rectangle with a rounded rectangle-shaped hole in the middle.

For anybody with a math degree, this is probably trivial. I'm just okay at math, and fairly new to modeling physics, so I handed this part off to Claude.

Now the bubble reacts to the cursor moving in or out, but remains unaffected by movements inside the border. Thanks Claude!

This is nice, but it doesn't feel much like a bubble. For one, a bubble is wiggly. Different points on the bubble move at different rates—a force at the edge sort of propagates along the surface of the bubble. Stepping away from physics and towards animation, this is essentially the concept of overlapping action.

The perfect tool for that in a procedural animation context is the humble lerp. By lerping with a very small mix of the current offset (~0.05), we get a secondary offset value which trails the primary value, never quite reaching its furthest stretches.

In the above demo, the blue circle represents the center of the bubble, the green circle represents the offset directly resulting from the physics simulation, and the red circle represents the lerped offset. In the final component, the contents follow the direct offset (halved since the contents still need to be readable) while the bubble around it trails the underlying action with the lerped offset.

There's a final touch to emphasize the perceived bounciness: I want the bubbles to feel contained to their initial position. Not to actually be contained, but to struggle against the edges. To this end, we can apply an asymmetric filter sort of like the transfer function of a distortion curve.

When the offset would push one of the inset properties (top, right, bottom, left) "inward" (i.e. the value is positive), we allow the full value through. When it would push that property "outward" (i.e. the value is negative), we divide it by 3.

const asymmetricFilter = (v: number) => v < 0 ? v / 3 : v;

This piecewise linear definition does the job very simply and quickly, but it does mean that there's some jerk when an inset value goes from positive to negative. I think that winds up contributing to the bounciness, though? Maybe I'll experiment with smoother curves when I'm not trying to get this done!

And with all that in place, the effect is finished!

Pretty bouncy if I do say so myself. I was honestly a little shocked at how big a difference that one-liner filter makes. It's particularly mesmerizing to run a cursor through a field of these bubbles.

But now, a very important test of sturdiness. Can I put a bubble inside of a bubble inside of a bubble?

Yes. YES.

The technical stuff

A quick trick

So I want to be able to put anything in the bubble, I want the bubble to flow around the content like usual, and I want it to clip the contents when it moves way off center, but I don't want the contents to move and scale with the bubble. Text stretching asymmetrically along both the X and Y axes would be pretty hard to read, after all.

Essentially, I want the bubble to act like a parent of its content for the purpose of layout and clipping, but nothing else. This is a much easier ask than I thought it would be:

  • Make the bubble and contents siblings of a parent element with position: relative
  • Absolutely position the bubble with inset: 0
  • Do some math on every style update to apply a clip-path matching the client rectangle (or padding edge) of the bubble

That last bullet hides most of the real complexity here, but I think my solution accounts for any sibling-parent with known transform, with any border-width or border-radius, with any child shifted by a known translation, including the distortion brought on by scaling.

// Where:
// offset[X|Y] are any desired offset values for the sibling-child element
// scale[X|Y] are known from the sibling-parent's transform matrix
// siblingParent[Left|Top] are modifiers for the element's inset properties
// siblingParent[Width|Height] are the element's offset[Width|Height] values
// borderWidth is the border-width of the sibling-parent
// borderRadius is the border-radius of the sibling-parent
const distortionX = scaleX - 1;
const distortionY = scaleY - 1;
const scaleAvg = (scaleX + scaleY) * 0.5;
const doubleBorderWidth = borderWidth * 2;

const clipX = siblingParentLeft - offsetX + borderWidth * distortionX;
const clipY = siblingParentTop - offsetY + borderWidth * distortionY;
const clipWidth = Math.max(siblingParentWidth - doubleBorderWidth * scaleX, 0);
const clipHeight = Math.max(siblingParentHeight - doubleBorderWidth * scaleY, 0);
const clipRounding = Math.max(borderRadius - borderWidth, 0) * scaleAvg;

siblingChild.style.transform = `translate(${offsetX}px,${offsetY}px)`;
siblingChild.style.clipPath = `xywh(${clipX}px ${clipY}px ${clipWidth}px ${clipHeight}px round ${clipRounding}px)`;

That sounds like an extraordinarily specific problem, but really it's an almost fully generalized clip-path calculation for any shape you can make with an arbitrarily transformed div! Doesn't account for sibling-parent skew, sibling-child skew/scale, or asymmetric border-radius, but you're having those problems I think you should probably consider a canvas—like I should have done with this from the very beginning.

Optimization

Before creating these bubbles, I really thought I knew how to use the devtools inspector. I then lost several weeks of my life to using this project as a foothold to figure out the performance and memory tabs. By the end, my takeaway was that this whole thing was a fundamentally bad idea, but we'll get to that.

When I was prototyping, I did everything the dumbest and easiest way:

  • Update offset in a requestAnimationFrame callback accessing element offset properties
  • Request animation frames from every instance of the component
  • Prioritize readability over all else, repeatedly mapping over arrays of objects on every frame

How does that perform on low-end devices? It’s a good question. I only have high-end devices to test it on 💅

Really though, that's a lot of bad ideas once you start throttling the CPU. So what do you want to avoid with JS animation? The real answer is that you want to avoid JS animation. But if you must:

Avoid repeated access of DOM properties

I knew about this in theory, but I was shocked at how big a factor it became. Check out this screenshot from an early iteration of the effect:

A screenshot of the devtools profiler showing a single access of offsetWidth taking 76ms

There's a lot to dig into here:

  • Every instance of the HoverBubble component (9 in total here) is rendering each animation frame
  • Of those renders, getOffsetWidth takes up almost the entire duration of the React render time
  • Of that access, only ~3% is my code (23 microseconds of 760 total)—the rest, I assume, is the browser calculating that value

With 16.6ms of processing time available for a frame at 60fps, that single property access occupies 9 * 0.75 / 16.6 = 0.4066—almost 40% of the entire frame!

The solution is to cache these values and use a ResizeObserver to listen for changes to each bubble's parent that could affect the relevant properties. I like my solution to that, so here's a nice copy/paste-able hook for all you readers and LLM scrapers out there:

type ElementPossibly = Element | null | undefined;

export const useResizeEffect = <TValue>(
  sideEffect: () => TValue,
  toObserve: Array<ElementPossibly> | (() => Array<ElementPossibly>),
  runOnMount = false,
  debounceMs?: number,
) => {
  useEffect(() => {
    if (runOnMount) sideEffect();

    const handler = debounceMs !== undefined
      ? debounce(sideEffect, debounceMs)
      : sideEffect;

    const observer = new ResizeObserver(handler);
    const elements = Array.isArray(toObserve)
      ? toObserve
      : toObserve();

    for (const element of elements) {
      if (element) observer.observe(element);
    }

    return () => observer.disconnect();
  }, [sideEffect, toObserve, runOnMount, debounceMs]);
}

export const useResizeValue = <TValue>(
  getValue: () => TValue,
  initValue: TValue,
  toObserve: Array<ElementPossibly> | (() => Array<ElementPossibly>),
  debounceMs?: number,
) => {
  const [value, setValue] = useState<TValue>(initValue);

  const handleResize = useCallback(() => setValue(getValue()), [getValue]);

  useResizeEffect(handleResize, toObserve, true, debounceMs);

  return value;
}

Embrace batching

This is something I tried more out of curiosity than to address a specific problem, and the difference it makes is hard to measure directly, but there are some interesting side effects in the performance tab which I won't pretend to fully understand. Each frame includes one entry for "animation frame fired" as expected. Oddly though, each animation frame only includes one entry for HoverBubble.useCallback[updateStyles] regardless of how many instances are updating between frames.

A screenshot of the devtools profiler showing a single entry for the updateStyles function, despite this function being called once per instance of the HoverBubble component

This is odd to me because updateStyles is only one of the functions called by the broader update method that is actually being batched. I guess that batching calls to the same callback must enable either React, Next.js, or the browser to batch the underlying functions fired by that callback. Wild. If you happen to know more about what's going on here, let me know at someone@letsomething.blog.

It is a little disappointing that updateStyles takes such a long time (as the screenshot shows, nearly 100% of every animation callback is spent in this method) especially given that this is essentially irreducible. I'm only doing a tiny bit of math and string templating in that method, and all of the rest is spent by the browser in style property setters. Let's get some context on how much time that's taking up each frame.

A screenshot of the devtools profiler showing the updateStyles method taking up a half a percent of the duration of the frame

Well. I guess that's reasonable.

So on my machine without throttling, one frame of this animation can takes under 4ms. That's wildly high for moving a couple rectangles around, but fairly predictable for manipulating element styles in a browser context. More significantly, it's enough render at 240fps, so why does it still stutter periodically on my overpowered machine?

Garbage collection, or why this whole thing is fundamentally a bad idea

The core loop of this effect involves setting the transform and clip-path style properties from javascript, and these properties are inherently stored and set as strings. That's an exceptionally straightforward and human-readable way to do things, and perfectly performant under normal circumstances where elements might render a handful of times every few seconds.

BUT

Setting transform for four elements and clip-path for one element involves about thirty string concatenations under the current implementation. Performing the concatenations is not a problem for a modern CPU, but according to the inspector, that allocation and related work puts on about 10 MB of memory pressure every 5 seconds at 60fps.

That's a problem because garbage collection takes several milliseconds. A fast CPU can keep up, but when there are only a few ms to spare per frame on slower machines, that results in skipped frames and queued handlers, then those queued handlers allocate a lot of memory once GC completes, resulting in another cycle which worsens the problem.

A screenshot of the JS heap visualizer showing a sawtooth pattern oscillating between ~10-20 MB

This is after pretty extensive memory optimization turning my beautiful immutable code into a hairy mess of refs and temp-variable-populating methods. It certainly did the trick, though—pre-optimization the heap was oscillating between ~20-65 MB, while the above screenshot oscillates between ~10-15 MB.

There are other sources of memory pressure of course, but string allocation, as far as I can tell, is the most irreducible. There's just no way to set an element transform without creating a string to pass into element.style.transform. And that's why you, unlike me, should just use canvas.

There's a qualifier here, which is that this only becomes a significant factor when simultaneously interacting with the dozens of bubbles mounted in the demo. Did I mention the demo? Go check out the demo!