transform-origin settings; hover or Tab to each link.text-decoration that is always visible.Three ways to draw an underline you can animate
A plain underline is either on or off. text-decoration-line animates discretely, so switching it from none to underline happens in one step however long the transition is. To make a line grow, draw or thicken, you need something with values in between, and CSS offers three: a pseudo-element you can scale, a background gradient whose size you can change, and the newer text-decoration longhands that take lengths and colours.
They look identical on a one-word link. They differ when a link breaks across two lines, which is exactly the case most underline snippets never test. The scaled pseudo-element is the version in our Underline Slide card, and a tab-bar variant lives in the Tab Underline Slide card. The demos above work with a mouse or the Tab key. For more ready-made snippets, browse the CSS hover effects category.
The pseudo-element underline: scaleX and transform-origin
The link gets position: relative, and an empty ::after is absolutely positioned along its bottom edge, 2px tall and filled with currentColor. At rest it is squashed to nothing with scaleX(0); on hover or keyboard focus it scales back to full width.
.ul-pseudo {
position: relative;
color: #e2e8f0;
text-decoration: none;
}
.ul-pseudo::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.ul-pseudo:focus-visible::after { transform: scaleX(1); }
@media (hover: hover) {
.ul-pseudo:hover::after { transform: scaleX(1); }
}
Only transform changes, so the browser paints the bar once and scales it on the compositor, which makes this the cheapest of the three techniques to run. currentColor keeps the bar in step with the text, even if the link colour also changes on hover. Setting left and right to 0 stretches the bar between the link’s edges without a width calculation, and the negative bottom drops it clear of descenders.
transform-origin decides where the growth starts. left grows rightwards. center expands outwards from the middle, the look used by the tab underline in our library. The third variant draws in from the left and leaves to the right:
.ul-centre::after { transform-origin: center; }
.ul-through::after { transform-origin: right; }
.ul-through:focus-visible::after { transform-origin: left; }
@media (hover: hover) {
.ul-through:hover::after { transform-origin: left; }
}
The trick is that the origin changes instantly while the scale transitions. On hover the origin jumps to the left while the bar is still zero width, so the jump is invisible, and the bar grows rightwards. On leave the origin snaps back to the right and the bar shrinks towards it, so it seems to slide out of the far side. Keep transform-origin out of the transition list, or the bar slides sideways mid-animation.
Why the pseudo-element breaks on wrapped links
An absolutely positioned child of an inline element uses that inline as its containing block. When the link wraps, the browser splits the inline into a fragment on each line, but ::after is still a single box. CSS defines the containing block as running from the start of the first fragment to the end of the last, a shape that matches neither line. Hover the top example in the second demo: the bar is drawn once, never under both lines. In current Chrome it sits under the last line only; other engines can place it differently, and the result also shifts with where the line happens to break.
In navigation that is rarely a problem. Menu items are short, and white-space: nowrap or display: inline-block keeps each one on a single line. In running text, card headings and any link that a translation might lengthen, you cannot promise a single line, so the pseudo-element is the wrong tool.
A gradient underline that follows the text
Backgrounds on inline elements are painted fragment by fragment, so a background can follow text round a wrap where a pseudo-element cannot. This technique paints a single-colour “gradient” (linear-gradient(currentColor, currentColor) is simply a way to turn one colour into a background image), sizes it to a thin strip along the bottom of the link, and animates background-size from 0% to 100% to draw the line.
.ul-grad {
color: #e2e8f0;
text-decoration: none;
background-image: linear-gradient(currentColor, currentColor);
background-repeat: no-repeat;
background-position: 0 100%;
background-size: 0% 2px;
transition: background-size 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.ul-grad:focus-visible { background-size: 100% 2px; }
@media (hover: hover) {
.ul-grad:hover { background-size: 100% 2px; }
}
background-position: 0 100% pins the strip to the left and the bottom, and no-repeat stops it tiling up the link. The second value of background-size is the line’s thickness, so 2px stays 2px whatever the width.
How the strip crosses the wrap depends on box-decoration-break. With the default, slice, the browser treats the fragments as one long line laid end to end, sizes the background against that total width and then cuts it into pieces. So 100% means the whole link, and the line draws along the first line before continuing on the second, in reading order. With clone, each fragment gets its own copy of the background, sized against that fragment alone, so every line draws at the same time:
.ul-clone {
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
Support needs a little care. MDN’s box-decoration-break reference marks the property as limited availability: Firefox has supported it unprefixed since version 32 and Chrome and Edge since version 130, while Safari only recognises -webkit-box-decoration-break, and the prefixed property applies only to inline elements. Links are inline, so writing both declarations covers current browsers, and anywhere clone is ignored you simply get the slice behaviour, which still works.
The in-left, out-right idea carries over too. Set background-position: 100% 100% at rest and 0 100% on hover, and transition only background-size; the position jumps while the strip has no width, just as the transform origin did.
The price is paint rather than compositing: changing background-size repaints the link on every frame, which is trivial for a few words but not for hundreds of elements at once. Our guide to CSS animation performance explains the difference.
Animating the real underline
The simplest modern option keeps a genuine text decoration and animates its details. Three longhands take values the browser can interpolate. MDN’s text-underline-offset reference lists it as animatable by computed value type and widely available since November 2020; text-decoration-thickness animates the same way and has been widely available since March 2021, and text-decoration-color animates as a colour.
.ul-deco {
color: #67e8f9;
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.3em;
text-decoration-color: rgba(103, 232, 249, 0.45);
transition:
text-decoration-thickness 0.2s ease,
text-underline-offset 0.2s ease,
text-decoration-color 0.2s ease;
}
.ul-deco:focus-visible {
text-decoration-thickness: 3px;
text-underline-offset: 0.15em;
text-decoration-color: #67e8f9;
}
@media (hover: hover) {
.ul-deco:hover {
text-decoration-thickness: 3px;
text-underline-offset: 0.15em;
text-decoration-color: #67e8f9;
}
}
Because this is a real underline, it wraps with the text, breaks around descenders where the browser skips ink, and needs no positioning or extra layers.
Two caveats. First, interpolation needs the same kind of value at both ends. auto and from-font are keywords, so a transition from text-decoration-thickness: auto to 3px jumps rather than animates; use lengths throughout, and note that text-underline-offset in em scales with the font. Second, underlines are drawn on the pixel grid, so going from 1px to 3px has only a couple of visible steps. Keep the transition short and let the offset and colour carry most of the change, as the third demo does: the line moving 0.15em closer to the text while it brightens reads as smooth even though the thickness steps.
| Technique | Survives line wraps | Animatable properties | Effort |
|---|---|---|---|
::after + scaleX() | No: one bar for a link split over several lines | transform (and opacity); origin switched instantly | Medium: positioned link, pseudo-element, two states |
background-image gradient + background-size | Yes: in reading order with slice, all lines at once with clone | background-size, background-position | Low: one element, a few declarations |
text-decoration-* longhands | Yes: it is the browser’s own underline | text-decoration-thickness, text-underline-offset, text-decoration-color (lengths and colours, not keywords) | Lowest: no extra boxes or positioning |
Toggling text-decoration-line | Yes | None: discrete, so the line just appears | None, but nothing to animate |
Keep the underline on links in prose
Most hover-underline snippets begin with text-decoration: none, which leaves the links in a paragraph identified by colour alone until someone points at them. The W3C’s Understanding document for 1.4.1 Use of Color lists links that are not visually evident without colour vision as a failure (F73). Its sufficient technique for colour-only links, G183, requires a contrast ratio of at least 3:1 between the link colour and the surrounding text colour, on top of the 4.5:1 that both need against the background. On a dark page with light grey body text, finding a link colour that meets all three ratios is hard, and a hover cue never reaches people on touchscreens.
So split the job. Links inside paragraphs, lists and table cells keep a visible underline at rest and use the third technique to emphasise it on hover and focus. The draw-on underlines from the first two techniques belong where position already says “this is a link”: navigation bars, footers, tab lists and card headings. Even there, mark the current page with more than a colour change. Our accessible website guide covers the rest of that checklist.
Focus, touch screens and reduced motion
Keyboard focus. Every rule that responds to :hover also responds to :focus-visible. In the demos the focus rules sit outside any media query so they always apply, and the browser’s focus outline stays, because a 2px line is a weak focus indicator on its own. :focus-visible rather than :focus means clicking a link with a mouse does not leave its underline drawn until focus moves elsewhere.
Touch screens. Phones and tablets cannot hover, but their browsers apply :hover on tap so that hover-only menus still open, which leaves the effect stuck on afterwards. MDN’s hover media feature reference explains that (hover: hover) matches when the primary input mechanism can conveniently hover, and (hover: none) when it cannot or can only emulate it with a long press. Wrapping the hover rules in @media (hover: hover) removes the sticky state, and since the animation is decoration, nothing is lost. It is another reason prose links need their underline at rest: on a touchscreen, the rest state is all anyone sees.
Reduced motion. A line growing across a word is small movement, but people who turn on their operating system’s reduce-motion setting have asked for less of it, and the information is the change of state, not the motion. Remove the transitions so the underline appears in its final form at once:
@media (prefers-reduced-motion: reduce) {
.ul-pseudo::after,
.ul-grad,
.ul-deco { transition: none; }
}
Only the transitions go; the hover and focus states still apply, instantly.