Hover

Recreating Linear's momentum chart with framer motion

Linear's marketing site has a small SVG illustration in the "Designed for speed" benefit card: fifteen 3D-looking bars stacked into a bell curve. Move your cursor across it and the wave follows. Bars near the cursor pop up to full height while everything else flattens, with a subtle dimming on the bars further away. It's the kind of small detail that quietly tells you the team building the product cares about the small stuff.

This tutorial walks through building it from scratch with framer motion. We'll start with the static geometry of a single bar, then layer the animation on top.

Building the bar geometry

Each bar is a 3D-looking parallelogram with a slanted top face. The SVG path has a fixed shape; only the height (v command in the path) and the top y-coordinate change as the bar grows or shrinks.

The first decision is where to put the bar's anchor point. Anchor it to the bottom-right corner and derive the top-left every frame as anchor − height. That keeps the bar's bottom fixed in place as it grows, so the animation feels like "rising from the ground" rather than "scaling from the centre."

defaulthoveredanchor (x, y)h
h = 120

It's tempting to store the bar as {x, y, h} where x, y is the top-left at the default height. That works for a static render but makes growing the bar awkward: you'd have to update y and h together and keep them in sync. Storing the anchor and computing the top-left every frame is the cleaner abstraction.

Here's the path, parameterised on a single h motion value:

const xCorner = anchor.x - 117.416;

const outerD = useTransform(h, (e) => {
  const yTop = anchor.y - 60.643 - e;
  return `M${xCorner} ${yTop}a1.44 1.44 0 0 1 1.288 0l115.686 57.843a3.13 3.13 0 0 1 1.73 2.8v${e}a1.44 1.44 0 0 1-.796 1.288l-1.69 .845a1.44 1.44 0 0 1-1.288 0l-115.686 -57.843a3.13 3.13 0 0 1-1.73 -2.8v${-e}c0 -.545 .308 -1.044 .796 -1.288z`;
});

The v${e} command sets the right edge's length to the current height, and yTop moves the top edge up by the same amount, so the bottom-right stays glued to anchor.

Sharing one motion value

The whole chart shares one motion value: the cursor's X coordinate, normalised into the SVG's viewBox space (0–272). Every bar subscribes to it.

let s = useMotionValue(1 / 0); // Infinity

return (
  <svg
    onMouseMove={(e) => {
      const rect = e.currentTarget.getBoundingClientRect();
      const x = ((e.clientX - rect.left) / rect.width) * 272;
      s.set(x);
    }}
    onMouseLeave={() => s.set(1 / 0)}
  >
    {anchors.map((anchor, i) => (
      <Bar
        key={i}
        mouseX={s}
        anchor={anchor}
        defaultHeight={defaults[i]}
      />
    ))}
  </svg>
);

Setting it to Infinity on mouseLeave is a useful trick: every bar's "distance to cursor" becomes infinite, so they all fall back to their default heights without a separate "is hovering" boolean.

No per-bar hover handlers. No group state. Just a number that the bars subscribe to.

Mapping distance to height

Each bar reads mouseX, computes its distance to its own anchor, and derives both height and opacity from that distance:

const Bar = ({ mouseX, anchor, defaultHeight }: BarProps) => {
  const center = anchor.x - 59;

  const targetH = useTransform(mouseX, (mx) => {
    if (!Number.isFinite(mx)) return defaultHeight;
    const d = Math.abs(mx - center);
    if (d <= 0) return 128;
    if (d <= 25) return 128 - (d / 25) * 88;        // 128 → 40
    if (d <= 60) return 40 - ((d - 25) / 35) * 28;  // 40 → 12
    return 12;
  });

  const targetOpacity = useTransform(mouseX, (mx) => {
    if (!Number.isFinite(mx)) return 1;
    const d = Math.abs(mx - center);
    if (d <= 20) return 1;
    if (d <= 70) return 1 - ((d - 20) / 50) * 0.6;
    return 0.4;
  });

  // ... build path d from h
};
Hover

The shape of the falloff does most of the visual work. There are three regimes for height:

  • Within 25 viewBox units of the cursor: linear ramp from 128 (max) down to 40.
  • 25–60 units: linear ramp from 40 down to 12.
  • Beyond 60 units: clamped at 12.

When the cursor is dead-on, that bar hits 128. The two or three on either side ride the steep part of the curve and reach 60–90. Everything else slumps to 12. Move the cursor a pixel and the assignments shift. That's the wave.

The opacity falloff is gentler: full opacity within 20 units, ramping to 0.4 by 70, which gives the bars near the cursor extra emphasis without making the distant ones disappear entirely.

A note on the colour space: Linear's original uses a CSS brightness filter. That works on their near-black page background because the strokes brighten visibly under the cursor. On a light background, brightness < 1 just makes white less white, which is hard to read. Swap it for opacity and you get the same spotlight effect in a different colour space.

Polishing the motion

Wrap both target values in a spring before passing them to the path:

const h = useSpring(targetH, { damping: 18, mass: 1 });
const opacity = useSpring(targetOpacity, { damping: 18, mass: 1 });

{ damping: 18, mass: 1 } is heavier and softer than you'd reach for by default. It's what makes the wave feel viscous instead of snappy, and it means the bars don't quite catch up to the cursor at high speeds, which reads as momentum rather than as lag.

Reusing the primitive

The architecture is what matters, not the geometry. Once you have one shared mouseX motion value and a falloff, you can rewrite the visual into something that looks nothing like a stack of cards and the interaction still reads the same way under your finger. Drop the parallelograms and build a row of thin vertical lines inside a pill (closer to an audio meter than a chart) wired up to the exact same Gaussian.

Hover

52 lines sit centred on a horizontal baseline inside the pill. Each line's height is the same Gaussian as before, evaluated at its own X minus the cursor's X. To make the line grow up and down rather than only upward, anchor it to a centred y: the rect's y attribute is CENTER_Y − height / 2, so as the height changes the line stretches symmetrically above and below the midline.

// 52 thin lines, each height a Gaussian centred on the cursor.
const Bar = ({ mouseX, hover, baseX }) => {
  const height = useTransform([mouseX, hover], ([mx, h]) => {
    const t = Math.exp(-((mx - baseX) ** 2) / (2 * SIGMA ** 2));
    return MIN_H + h * t * (MAX_H - MIN_H);
  });

  // Centre on a baseline so the line grows up AND down.
  const y = useTransform(height, (e) => CENTER_Y - e / 2);

  return (
    <motion.rect
      x={baseX - 0.8}
      y={y}
      width={1.6}
      height={height}
      rx={0.8}
      fill="#52525b"
    />
  );
};

One motion value, one falloff. Stacked cards or an EQ-style meter, both share the exact same input loop and the exact same Gaussian. When the architecture is right, the visual is just a styling choice.

If you're building something similar and want to compare notes, I'm on X or at ayodeleoluwasina@gmail.com.