Sliding a Color Change Into Text Links on Hover
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.



