Animating position on hover with a Caterpillar UI Component in React

It is a common pattern in UI design to create a component that changes state on hover, but it can be tricky if you want to change position. If you stop hovering, and the element moves back over the mouse, it can create a flicker effect of hover -> not hover -> hover again.

Caterpillar with flicker

The offending code might look something like this. Simple but not quite right.

export default function Caterpillar({ text, className }: { text: string, className?: string }) {
  const chars = text.split('')
  return (
    <div className={`flex ${className}`}>
      {chars.map((c, i) => (
        <div key={i} className={`flex ${i === 0 ? 'rounded-s-lg' : ''} ${i === chars.length - 1 ? 'rounded-e-lg' : ''} border-top border-bottom border-2 border-brand bg-primary text-background -ml-[2px] pb-[0.1rem] px-2 transition-transform hover:-translate-y-1`}>
          <span>{c}</span>
        </div>
      ))}
    </div>
  )
}

We can solve this by using onMouseEnter on the child element and onMouseLeave on the parent.

'use client'

import { useState } from 'react'

export default function Caterpillar({ text, className }: { text: string, className?: string }) {
  const [selected, setSelected] = useState<number | null>(null)

  const chars = text.split('')
  return (
    <div className={`flex ${className}`} onMouseLeave={() => { setSelected(null) }}>
      {chars.map((c, i) => (
        <div key={i} className={`flex ${i === 0 ? 'rounded-s-lg' : ''} ${i === chars.length - 1 ? 'rounded-e-lg' : ''} border-top border-bottom border-2 border-brand bg-primary text-background -ml-[2px] pb-[0.1rem] px-2 transition-transform ${selected === i ? '-translate-y-1' : ''}`} onMouseEnter={() => { setSelected(i) }}>
          <span>{c}</span>
        </div>
      ))}
    </div>
  )
}

Feel free to copy that code if you're using tailwind and React. Note that it assumes you have a color called brand.

Caterpillar working as intended

Nice!