Building a Sliding Hover Highlight in React

Live demo - hover or tap the items

Simplicity is the ultimate sophistication.

The details are not the details. They make the design.

Less, but better.

Stay hungry, stay foolish.

There's a specific interaction pattern I keep coming back to. You hover over a list of items, and instead of each item having its own independent hover state, a single highlight slides smoothly from one item to the next. It feels fluid, intentional, almost physical - like dragging a magnifying glass across a page.

I built this for the design quotes page on my site, and I want to walk through exactly how it works. No animation library. No framer-motion. Just React state, getBoundingClientRect, and one absolutely positioned div.

The Core Idea

Most hover effects work per-element. You add :hover styles and each item lights up independently. The problem is that when you move between items, the old one fades out and the new one fades in. There's no continuity. No sense that the highlight is a single object moving through space.

The sliding highlight flips this model. Instead of styling each item on hover, you have one floating element that repositions itself to wherever the cursor is. The items themselves don't change their background at all - the highlight lives behind them.

The Markup

The structure is simple. A container with position: relative, a floating highlight div with position: absolute, and the list items stacked on top with position: relative and z-index:

<div ref={containerRef} className="relative flex flex-col" onMouseLeave={handleMouseLeave}>
  {/* The floating highlight */}
  <div
    className="absolute left-0 right-0 bg-secondary rounded-lg pointer-events-none"
    style={{
      top: hoverStyle.top,
      height: hoverStyle.height,
      opacity: hoverStyle.opacity,
      transition: 'top 200ms ease, height 200ms ease, opacity 150ms ease',
    }}
  />

  {/* The actual items */}
  {items.map((item, index) => (
    <p
      key={index}
      onMouseEnter={(e) => activateItem(e.currentTarget, index)}
      className="relative z-10 px-4 py-2 cursor-pointer"
    >
      {item}
    </p>
  ))}
</div>

The highlight div has pointer-events-none so it doesn't interfere with mouse events on the items beneath it. It spans the full width with left-0 right-0. The items sit above it with z-10.

The State

We only need two pieces of state - the position/size of the highlight, and which item is currently active:

const containerRef = useRef<HTMLDivElement>(null);

const [hoverStyle, setHoverStyle] = useState<{
  top: number;
  height: number;
  opacity: number;
}>({ top: 0, height: 0, opacity: 0 });

const [activeIndex, setActiveIndex] = useState<number | null>(null);

The highlight starts with opacity: 0 so it's invisible until the first hover. The top and height values position it exactly over the active item.

The Position Calculation

This is the key part. When the cursor enters an item, we need to calculate where the highlight should be. We do this by comparing the item's position to the container's position:

const activateItem = (el: HTMLElement, index: number) => {
  const container = containerRef.current;
  if (!container) return;

  const containerRect = container.getBoundingClientRect();
  const itemRect = el.getBoundingClientRect();

  setHoverStyle({
    top: itemRect.top - containerRect.top,
    height: itemRect.height,
    opacity: 1,
  });
  setActiveIndex(index);
};

getBoundingClientRect() returns the element's position relative to the viewport. By subtracting the container's top from the item's top, we get the item's position relative to the container. This is exactly what we need for the top value of our absolutely positioned highlight.

The height matches the item's height exactly, so the highlight perfectly covers each item regardless of how tall it is. This means items with different amounts of text get appropriately sized highlights.

Why This Feels Smooth

The magic is in one line of CSS:

transition: top 200ms ease, height 200ms ease, opacity 150ms ease;

When you move from item A to item B, the highlight doesn't disappear and reappear. Its top value changes, and the transition animates it from the old position to the new one. The browser handles the interpolation. The result is a single element that physically slides between positions.

200ms for position and height gives a snappy but visible motion. The ease curve means it starts fast and decelerates, which feels natural - like the highlight has momentum.

Opacity gets a slightly faster 150ms because the fade-in and fade-out should feel instant compared to the sliding motion. You don't want the highlight lingering as a ghost while it's supposed to be gone.

The Mouse Leave Handler

When the cursor leaves the container entirely, we fade the highlight out:

const handleMouseLeave = () => {
  setHoverStyle((prev) => ({ ...prev, opacity: 0 }));
  setActiveIndex(null);
};

Notice we spread the previous state and only change opacity. We don't reset top or height. This is intentional - if we reset everything, the highlight would jump to position 0 while fading, creating a weird flash. By only changing opacity, it fades out in place. Clean.

The Text Color Shift

The highlight is only half the effect. The other half is the text changing color to indicate which item is active:

<p
  className={`relative z-10 px-4 py-2 transition-colors ${
    activeIndex === index ? 'text-foreground' : 'text-muted'
  }`}
>

Inactive items are muted. The active item is full foreground. Combined with the background highlight sliding underneath, this creates a clear focus indicator that moves as a unit.

Adding Click-to-Toggle

I added one extra interaction - clicking an item toggles it. Click once to lock the highlight, click again to release:

const handleClick = (e: React.MouseEvent, index: number) => {
  if (activeIndex === index) {
    setHoverStyle((prev) => ({ ...prev, opacity: 0 }));
    setActiveIndex(null);
  } else {
    activateItem(e.currentTarget, index);
  }
};

This is nice on mobile where there's no hover. Tap to highlight, tap again to dismiss. Same interaction model, different input.

Where This Pattern Works

I've used this same approach for navigation menus, blog post lists, settings panels - anywhere you have a vertical list of items that the user scans through. It works especially well when items have varying heights, because the highlight adapts to each one.

The key constraint is that items need to be in a single container with no gaps or overlapping margins. The position calculation assumes the items are stacked directly inside the container. If you have complex nested layouts, the rect subtraction might not give you the right values.

What I Like About This

No dependencies. No animation runtime. No keyframes. Just the browser doing what it does best - interpolating between two numeric values over time. The entire interaction is driven by state changes that trigger CSS transitions. React handles the "what" and CSS handles the "how."

The whole thing is about sixty lines of code, and every line is doing something you can point to and explain. That's the kind of interaction code I want to write - complex enough to feel polished, simple enough to debug at midnight.