A simple color swap on hover is fine, but sliding the new color in feels more polished. There are a few pure CSS ways to build that effect, each with different trade-offs in performance, accessibility, and browser support.

Background-Clip: Text

This method relies on background-clip: text, which is still experimental and unsupported in Internet Explorer 11 and below. The idea is to create knockout text using a hard-stop linear gradient, then animate the gradient’s position on hover.

a {
  position: relative;
  display: inline-block;
  font-size: 2em;
  font-weight: 800;
  color: royalblue;
  overflow: hidden;
}

Set the gradient with a 50% hard stop between the starting and ending colors:

a {
  /* Same as before */
  background: linear-gradient(to right, midnightblue, midnightblue 50%, royalblue 50%);
}

Then clip the gradient to the text and position it so only the starting color shows:

a {
  /* Same as before */
  background-clip: text;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-size: 200% 100%;
  background-position: 100%;
}

Finally, transition the background-position on hover:

a {
  /* Same as before */
  transition: background-position 275ms ease;
}
a:hover {
  background-position: 0 100%;
}

The catch: Safari and Chrome clip text decorations and shadows when background-clip: text is used. So an underline via text-decoration won’t render. If you need underlines, you’ll have to rely on another technique.

Width and Height

This approach duplicates the link text in a data-content attribute and uses a ::before pseudo-element to overlay a colored copy of it. The overlay starts at width: 0 and expands to 100% on hover.

<a href="#">Link Hover</a>
a {
  position: relative;
  display: inline-block;
  font-size: 2em;
  color: royalblue;
  font-weight: 800;
  text-decoration: underline;
  overflow: hidden;
}

Set up the pseudo-element with the duplicated text via attr():

a::before {
  position: absolute;
  content: attr(data-content); /* Prints the value of the attribute */
  top: 0;
  left: 0;
  color: midnightblue;
  text-decoration: underline;
  overflow: hidden;
  transition: width 275ms ease;
}

Keep it from wrapping and give it its own color:

a::before {
  /* Same as before */
  width: 0;
  white-space: nowrap;
}

Expand the overlay on hover:

a:hover::before {
  width: 100%;
}

A real text-decoration underline works fine here, which is a plus. But animating width and height is not performance-friendly — these properties trigger layout. For smooth 60fps transitions, transform or opacity are better choices.

Clip-Path

The same data-content markup works here, but the overlay is revealed with a clip-path polygon instead of measured dimensions. The polygon starts collapsed and expands on hover:

clip-path: polygon(0 0, 0 0, 0% 100%, 0 100%);

The four polygon coordinates map to the corners in order: top left, top right, bottom right, bottom left. Changing the fill direction is just a matter of adjusting those points. Expanding to the right means moving the right-side vertices:

a:hover::before {
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}

One thing worth noting: text-decoration: underline must be applied to the ::before pseudo-element itself for the underline to participate in the fill effect. And browser support for clip-path is uneven. It’s a better transition than width/height since it avoids layout, but it still triggers a repaint.

Transform

This version is the most cross-browser friendly and the smoothest, since transform doesn’t cause repaints. The trade-off is more markup: a <span> wraps the link text, and the duplicate content lives inside a data-content attribute on that span.

<a href="#"><span aria-hidden="true"></span>Link Hover</a>

Mark the duplicated content as hidden from screen readers with aria-hidden="true" so visitors using assistive technology hear the text only once. Style the span to reveal the overlay via translateX():

span {
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
  transform: translateX(-100%);
  transition: transform 275ms ease;
}

Start the span at a zero translation:

a:hover span {
  transform: translateX(0);
}

Then position the ::before pseudo-element, using the data-content value, shifted fully outside the span’s box.

span::before { 
  display: inline-block;
  content: attr(data-content);
  color: midnightblue;
  transform: translateX(100%);
  transition: transform 275ms ease;
  text-decoration: underline;
}

Bring it back to zero on the ::before as well:

a:hover span::before {
  transform: translateX(0);
}

Picking an Approach

All four methods result in the same sliding color effect. The deciding factors are practical: whether you need a real underline, which browsers you’re targeting, how much wrapper markup you’re willing to add, and whether you value that 60fps transition more than code brevity.