30 CSS Text Hover Effects01 / 30
CSS Animated Underline on Hover
The four underline animations every navbar ships in 2026, side by side: grow left→right, bloom from center, slide-through (the line exits the far side instead of retreating), and the pure text-decoration version that needs no pseudo-element at all. The first three are one background-image gradient sized from 0%→100% — cheaper than ::after, works on inline links that wrap — and the fourth animates text-decoration-color, thickness and underline-offset directly, which finally transitions in every modern engine.
Published
The code
<section class="cth-01" aria-label="Animated underline on hover demo">
<div class="cth-01__stage">
<div class="cth-01__scene">
<header class="cth-01__masthead">
<a class="cth-01__brand" href="#cth01-top" id="cth01-top">Ledger<span>&</span>Loom</a>
<span class="cth-01__chip">background-size: 0% → 100%</span>
</header>
<nav class="cth-01__nav" aria-label="Underline animation styles">
<div class="cth-01__row">
<span class="cth-01__tag">01 · left → right</span>
<a class="cth-01__link cth-01__link--ltr" href="#cth01-top">Selected work</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">02 · center out</span>
<a class="cth-01__link cth-01__link--center" href="#cth01-top">Studio journal</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">03 · slide through</span>
<a class="cth-01__link cth-01__link--thru" href="#cth01-top">About the practice</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">04 · text-decoration</span>
<a class="cth-01__link cth-01__link--native" href="#cth01-top">Get in touch</a>
</div>
</nav>
<p class="cth-01__prose">Underlines also live inside prose: the <a href="#cth01-top">type specimens</a> and the <a href="#cth01-top">2026 grid audit</a> use the same gradient at marker weight — and because they're real inline links, they keep underlining when they wrap.</p>
<p class="cth-01__hint">Hover the links — or press <kbd>Tab</kbd>. Every variant also fires on <code>:focus-visible</code>.</p>
</div>
</div>
</section><section class="cth-01" aria-label="Animated underline on hover demo">
<div class="cth-01__stage">
<div class="cth-01__scene">
<header class="cth-01__masthead">
<a class="cth-01__brand" href="#cth01-top" id="cth01-top">Ledger<span>&</span>Loom</a>
<span class="cth-01__chip">background-size: 0% → 100%</span>
</header>
<nav class="cth-01__nav" aria-label="Underline animation styles">
<div class="cth-01__row">
<span class="cth-01__tag">01 · left → right</span>
<a class="cth-01__link cth-01__link--ltr" href="#cth01-top">Selected work</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">02 · center out</span>
<a class="cth-01__link cth-01__link--center" href="#cth01-top">Studio journal</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">03 · slide through</span>
<a class="cth-01__link cth-01__link--thru" href="#cth01-top">About the practice</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">04 · text-decoration</span>
<a class="cth-01__link cth-01__link--native" href="#cth01-top">Get in touch</a>
</div>
</nav>
<p class="cth-01__prose">Underlines also live inside prose: the <a href="#cth01-top">type specimens</a> and the <a href="#cth01-top">2026 grid audit</a> use the same gradient at marker weight — and because they're real inline links, they keep underlining when they wrap.</p>
<p class="cth-01__hint">Hover the links — or press <kbd>Tab</kbd>. Every variant also fires on <code>:focus-visible</code>.</p>
</div>
</div>
</section>.cth-01,
.cth-01 *,
.cth-01 *::before,
.cth-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-01 {
--bg: oklch(0.975 0.007 95);
--ink: oklch(0.25 0.02 55);
--mut: oklch(0.52 0.02 60);
--acc: oklch(0.60 0.19 33);
--line: oklch(0.89 0.012 85);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-01__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px);
container: cth01/inline-size;
}
.cth-01__scene {
width: min(100%,780px);
display: flex;
flex-direction: column;
gap: clamp(20px,3.5cqi,30px);
}
.cth-01__masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1.5px solid var(--ink);
}
.cth-01__brand {
font-size: 15px;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
color: inherit;
text-decoration: none;
}
.cth-01__brand span {
color: var(--acc);
}
.cth-01__brand:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 4px;
}
.cth-01__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
font-weight: 600;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 45%,transparent);
border-radius: 99px;
padding: 5px 11px;
white-space: nowrap;
}
.cth-01__row {
display: grid;
grid-template-columns: 170px 1fr;
gap: 6px 24px;
align-items: baseline;
padding: clamp(12px,2.2cqi,18px) 0;
border-bottom: 1px solid var(--line);
}
.cth-01__tag {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mut);
}
.cth-01__link {
justify-self: start;
font-size: clamp(24px,4.6cqi,38px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.2;
color: inherit;
text-decoration: none;
padding-bottom: .12em;
}
.cth-01__link:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 5px;
border-radius: 2px;
}
.cth-01__link--ltr,
.cth-01__link--center,
.cth-01__link--thru {
background-image: linear-gradient(var(--acc),var(--acc));
background-repeat: no-repeat;
background-size: 0% .09em;
transition: background-size .45s cubic-bezier(.22,1,.36,1);
}
.cth-01__link--ltr {
background-position: 0% 100%;
}
.cth-01__link--center {
background-position: 50% 100%;
}
.cth-01__link--thru {
background-position: 100% 100%;
}
.cth-01__link--ltr:hover,
.cth-01__link--ltr:focus-visible,
.cth-01__link--center:hover,
.cth-01__link--center:focus-visible {
background-size: 100% .09em;
}
.cth-01__link--thru:hover,
.cth-01__link--thru:focus-visible {
background-position: 0% 100%;
background-size: 100% .09em;
}
.cth-01__link--native {
text-decoration-line: underline;
text-decoration-color: transparent;
text-decoration-thickness: .06em;
text-underline-offset: .32em;
text-decoration-skip-ink: auto;
transition: text-decoration-color .35s ease,text-underline-offset .35s cubic-bezier(.22,1,.36,1),text-decoration-thickness .35s ease;
}
.cth-01__link--native:hover,
.cth-01__link--native:focus-visible {
text-decoration-color: var(--acc);
text-underline-offset: .14em;
text-decoration-thickness: .1em;
}
.cth-01__prose {
font-size: 15px;
line-height: 1.75;
color: var(--mut);
max-width: 58ch;
}
.cth-01__prose a {
color: var(--ink);
font-weight: 650;
text-decoration: none;
background-image: linear-gradient(color-mix(in oklch,var(--acc) 32%,transparent),color-mix(in oklch,var(--acc) 32%,transparent));
background-repeat: no-repeat;
background-position: 0 88%;
background-size: 100% .14em;
transition: background-size .3s ease;
}
.cth-01__prose a:hover,
.cth-01__prose a:focus-visible {
background-size: 100% .6em;
}
.cth-01__prose a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 3px;
}
.cth-01__hint {
font-size: 12.5px;
color: var(--mut);
}
.cth-01__hint kbd {
font-family: ui-monospace,Menlo,monospace;
font-size: 11px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 2px 6px;
background: oklch(1 0 0);
}
.cth-01__hint code {
font-family: ui-monospace,Menlo,monospace;
font-size: .92em;
background: oklch(0.93 0.01 95);
padding: 1px 5px;
border-radius: 4px;
}
@container cth01 (width < 560px) {
.cth-01__row {
grid-template-columns: 1fr;
}
.cth-01__chip {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cth-01 * {
transition-duration: .01ms !important;
}
}.cth-01,
.cth-01 *,
.cth-01 *::before,
.cth-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-01 {
--bg: oklch(0.975 0.007 95);
--ink: oklch(0.25 0.02 55);
--mut: oklch(0.52 0.02 60);
--acc: oklch(0.60 0.19 33);
--line: oklch(0.89 0.012 85);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-01__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px);
container: cth01/inline-size;
}
.cth-01__scene {
width: min(100%,780px);
display: flex;
flex-direction: column;
gap: clamp(20px,3.5cqi,30px);
}
.cth-01__masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1.5px solid var(--ink);
}
.cth-01__brand {
font-size: 15px;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
color: inherit;
text-decoration: none;
}
.cth-01__brand span {
color: var(--acc);
}
.cth-01__brand:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 4px;
}
.cth-01__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
font-weight: 600;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 45%,transparent);
border-radius: 99px;
padding: 5px 11px;
white-space: nowrap;
}
.cth-01__row {
display: grid;
grid-template-columns: 170px 1fr;
gap: 6px 24px;
align-items: baseline;
padding: clamp(12px,2.2cqi,18px) 0;
border-bottom: 1px solid var(--line);
}
.cth-01__tag {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mut);
}
.cth-01__link {
justify-self: start;
font-size: clamp(24px,4.6cqi,38px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.2;
color: inherit;
text-decoration: none;
padding-bottom: .12em;
}
.cth-01__link:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 5px;
border-radius: 2px;
}
.cth-01__link--ltr,
.cth-01__link--center,
.cth-01__link--thru {
background-image: linear-gradient(var(--acc),var(--acc));
background-repeat: no-repeat;
background-size: 0% .09em;
transition: background-size .45s cubic-bezier(.22,1,.36,1);
}
.cth-01__link--ltr {
background-position: 0% 100%;
}
.cth-01__link--center {
background-position: 50% 100%;
}
.cth-01__link--thru {
background-position: 100% 100%;
}
.cth-01__link--ltr:hover,
.cth-01__link--ltr:focus-visible,
.cth-01__link--center:hover,
.cth-01__link--center:focus-visible {
background-size: 100% .09em;
}
.cth-01__link--thru:hover,
.cth-01__link--thru:focus-visible {
background-position: 0% 100%;
background-size: 100% .09em;
}
.cth-01__link--native {
text-decoration-line: underline;
text-decoration-color: transparent;
text-decoration-thickness: .06em;
text-underline-offset: .32em;
text-decoration-skip-ink: auto;
transition: text-decoration-color .35s ease,text-underline-offset .35s cubic-bezier(.22,1,.36,1),text-decoration-thickness .35s ease;
}
.cth-01__link--native:hover,
.cth-01__link--native:focus-visible {
text-decoration-color: var(--acc);
text-underline-offset: .14em;
text-decoration-thickness: .1em;
}
.cth-01__prose {
font-size: 15px;
line-height: 1.75;
color: var(--mut);
max-width: 58ch;
}
.cth-01__prose a {
color: var(--ink);
font-weight: 650;
text-decoration: none;
background-image: linear-gradient(color-mix(in oklch,var(--acc) 32%,transparent),color-mix(in oklch,var(--acc) 32%,transparent));
background-repeat: no-repeat;
background-position: 0 88%;
background-size: 100% .14em;
transition: background-size .3s ease;
}
.cth-01__prose a:hover,
.cth-01__prose a:focus-visible {
background-size: 100% .6em;
}
.cth-01__prose a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 3px;
}
.cth-01__hint {
font-size: 12.5px;
color: var(--mut);
}
.cth-01__hint kbd {
font-family: ui-monospace,Menlo,monospace;
font-size: 11px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 2px 6px;
background: oklch(1 0 0);
}
.cth-01__hint code {
font-family: ui-monospace,Menlo,monospace;
font-size: .92em;
background: oklch(0.93 0.01 95);
padding: 1px 5px;
border-radius: 4px;
}
@container cth01 (width < 560px) {
.cth-01__row {
grid-template-columns: 1fr;
}
.cth-01__chip {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cth-01 * {
transition-duration: .01ms !important;
}
}Here's a working CSS Text Hover Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Animated Underline on Hover
Source: https://codefronts.com/motion/css-text-hover-effects/css-animated-underline-on-hover/
The four underline animations every navbar ships in 2026, side by side: grow left→right, bloom from center, slide-through (the line exits the far side instead of retreating), and the pure text-decoration version that needs no pseudo-element at all. The first three are one background-image gradient sized from 0%→100% — cheaper than ::after, works on inline links that wrap — and the fourth animates text-decoration-color, thickness and underline-offset directly, which finally transitions in every modern engine.
## HTML
```html
<section class="cth-01" aria-label="Animated underline on hover demo">
<div class="cth-01__stage">
<div class="cth-01__scene">
<header class="cth-01__masthead">
<a class="cth-01__brand" href="#cth01-top" id="cth01-top">Ledger<span>&</span>Loom</a>
<span class="cth-01__chip">background-size: 0% → 100%</span>
</header>
<nav class="cth-01__nav" aria-label="Underline animation styles">
<div class="cth-01__row">
<span class="cth-01__tag">01 · left → right</span>
<a class="cth-01__link cth-01__link--ltr" href="#cth01-top">Selected work</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">02 · center out</span>
<a class="cth-01__link cth-01__link--center" href="#cth01-top">Studio journal</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">03 · slide through</span>
<a class="cth-01__link cth-01__link--thru" href="#cth01-top">About the practice</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">04 · text-decoration</span>
<a class="cth-01__link cth-01__link--native" href="#cth01-top">Get in touch</a>
</div>
</nav>
<p class="cth-01__prose">Underlines also live inside prose: the <a href="#cth01-top">type specimens</a> and the <a href="#cth01-top">2026 grid audit</a> use the same gradient at marker weight — and because they're real inline links, they keep underlining when they wrap.</p>
<p class="cth-01__hint">Hover the links — or press <kbd>Tab</kbd>. Every variant also fires on <code>:focus-visible</code>.</p>
</div>
</div>
</section>
```
## CSS
```css
.cth-01,
.cth-01 *,
.cth-01 *::before,
.cth-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-01 {
--bg: oklch(0.975 0.007 95);
--ink: oklch(0.25 0.02 55);
--mut: oklch(0.52 0.02 60);
--acc: oklch(0.60 0.19 33);
--line: oklch(0.89 0.012 85);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-01__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px);
container: cth01/inline-size;
}
.cth-01__scene {
width: min(100%,780px);
display: flex;
flex-direction: column;
gap: clamp(20px,3.5cqi,30px);
}
.cth-01__masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1.5px solid var(--ink);
}
.cth-01__brand {
font-size: 15px;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
color: inherit;
text-decoration: none;
}
.cth-01__brand span {
color: var(--acc);
}
.cth-01__brand:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 4px;
}
.cth-01__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
font-weight: 600;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 45%,transparent);
border-radius: 99px;
padding: 5px 11px;
white-space: nowrap;
}
.cth-01__row {
display: grid;
grid-template-columns: 170px 1fr;
gap: 6px 24px;
align-items: baseline;
padding: clamp(12px,2.2cqi,18px) 0;
border-bottom: 1px solid var(--line);
}
.cth-01__tag {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mut);
}
.cth-01__link {
justify-self: start;
font-size: clamp(24px,4.6cqi,38px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.2;
color: inherit;
text-decoration: none;
padding-bottom: .12em;
}
.cth-01__link:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 5px;
border-radius: 2px;
}
.cth-01__link--ltr,
.cth-01__link--center,
.cth-01__link--thru {
background-image: linear-gradient(var(--acc),var(--acc));
background-repeat: no-repeat;
background-size: 0% .09em;
transition: background-size .45s cubic-bezier(.22,1,.36,1);
}
.cth-01__link--ltr {
background-position: 0% 100%;
}
.cth-01__link--center {
background-position: 50% 100%;
}
.cth-01__link--thru {
background-position: 100% 100%;
}
.cth-01__link--ltr:hover,
.cth-01__link--ltr:focus-visible,
.cth-01__link--center:hover,
.cth-01__link--center:focus-visible {
background-size: 100% .09em;
}
.cth-01__link--thru:hover,
.cth-01__link--thru:focus-visible {
background-position: 0% 100%;
background-size: 100% .09em;
}
.cth-01__link--native {
text-decoration-line: underline;
text-decoration-color: transparent;
text-decoration-thickness: .06em;
text-underline-offset: .32em;
text-decoration-skip-ink: auto;
transition: text-decoration-color .35s ease,text-underline-offset .35s cubic-bezier(.22,1,.36,1),text-decoration-thickness .35s ease;
}
.cth-01__link--native:hover,
.cth-01__link--native:focus-visible {
text-decoration-color: var(--acc);
text-underline-offset: .14em;
text-decoration-thickness: .1em;
}
.cth-01__prose {
font-size: 15px;
line-height: 1.75;
color: var(--mut);
max-width: 58ch;
}
.cth-01__prose a {
color: var(--ink);
font-weight: 650;
text-decoration: none;
background-image: linear-gradient(color-mix(in oklch,var(--acc) 32%,transparent),color-mix(in oklch,var(--acc) 32%,transparent));
background-repeat: no-repeat;
background-position: 0 88%;
background-size: 100% .14em;
transition: background-size .3s ease;
}
.cth-01__prose a:hover,
.cth-01__prose a:focus-visible {
background-size: 100% .6em;
}
.cth-01__prose a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 3px;
}
.cth-01__hint {
font-size: 12.5px;
color: var(--mut);
}
.cth-01__hint kbd {
font-family: ui-monospace,Menlo,monospace;
font-size: 11px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 2px 6px;
background: oklch(1 0 0);
}
.cth-01__hint code {
font-family: ui-monospace,Menlo,monospace;
font-size: .92em;
background: oklch(0.93 0.01 95);
padding: 1px 5px;
border-radius: 4px;
}
@container cth01 (width < 560px) {
.cth-01__row {
grid-template-columns: 1fr;
}
.cth-01__chip {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cth-01 * {
transition-duration: .01ms !important;
}
}
```Here's a working CSS Text Hover Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Animated Underline on Hover
Source: https://codefronts.com/motion/css-text-hover-effects/css-animated-underline-on-hover/
The four underline animations every navbar ships in 2026, side by side: grow left→right, bloom from center, slide-through (the line exits the far side instead of retreating), and the pure text-decoration version that needs no pseudo-element at all. The first three are one background-image gradient sized from 0%→100% — cheaper than ::after, works on inline links that wrap — and the fourth animates text-decoration-color, thickness and underline-offset directly, which finally transitions in every modern engine.
## HTML
```html
<section class="cth-01" aria-label="Animated underline on hover demo">
<div class="cth-01__stage">
<div class="cth-01__scene">
<header class="cth-01__masthead">
<a class="cth-01__brand" href="#cth01-top" id="cth01-top">Ledger<span>&</span>Loom</a>
<span class="cth-01__chip">background-size: 0% → 100%</span>
</header>
<nav class="cth-01__nav" aria-label="Underline animation styles">
<div class="cth-01__row">
<span class="cth-01__tag">01 · left → right</span>
<a class="cth-01__link cth-01__link--ltr" href="#cth01-top">Selected work</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">02 · center out</span>
<a class="cth-01__link cth-01__link--center" href="#cth01-top">Studio journal</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">03 · slide through</span>
<a class="cth-01__link cth-01__link--thru" href="#cth01-top">About the practice</a>
</div>
<div class="cth-01__row">
<span class="cth-01__tag">04 · text-decoration</span>
<a class="cth-01__link cth-01__link--native" href="#cth01-top">Get in touch</a>
</div>
</nav>
<p class="cth-01__prose">Underlines also live inside prose: the <a href="#cth01-top">type specimens</a> and the <a href="#cth01-top">2026 grid audit</a> use the same gradient at marker weight — and because they're real inline links, they keep underlining when they wrap.</p>
<p class="cth-01__hint">Hover the links — or press <kbd>Tab</kbd>. Every variant also fires on <code>:focus-visible</code>.</p>
</div>
</div>
</section>
```
## CSS
```css
.cth-01,
.cth-01 *,
.cth-01 *::before,
.cth-01 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-01 {
--bg: oklch(0.975 0.007 95);
--ink: oklch(0.25 0.02 55);
--mut: oklch(0.52 0.02 60);
--acc: oklch(0.60 0.19 33);
--line: oklch(0.89 0.012 85);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-01__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px);
container: cth01/inline-size;
}
.cth-01__scene {
width: min(100%,780px);
display: flex;
flex-direction: column;
gap: clamp(20px,3.5cqi,30px);
}
.cth-01__masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1.5px solid var(--ink);
}
.cth-01__brand {
font-size: 15px;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
color: inherit;
text-decoration: none;
}
.cth-01__brand span {
color: var(--acc);
}
.cth-01__brand:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 4px;
}
.cth-01__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
font-weight: 600;
color: var(--acc);
border: 1px solid color-mix(in oklch,var(--acc) 45%,transparent);
border-radius: 99px;
padding: 5px 11px;
white-space: nowrap;
}
.cth-01__row {
display: grid;
grid-template-columns: 170px 1fr;
gap: 6px 24px;
align-items: baseline;
padding: clamp(12px,2.2cqi,18px) 0;
border-bottom: 1px solid var(--line);
}
.cth-01__tag {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mut);
}
.cth-01__link {
justify-self: start;
font-size: clamp(24px,4.6cqi,38px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.2;
color: inherit;
text-decoration: none;
padding-bottom: .12em;
}
.cth-01__link:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 5px;
border-radius: 2px;
}
.cth-01__link--ltr,
.cth-01__link--center,
.cth-01__link--thru {
background-image: linear-gradient(var(--acc),var(--acc));
background-repeat: no-repeat;
background-size: 0% .09em;
transition: background-size .45s cubic-bezier(.22,1,.36,1);
}
.cth-01__link--ltr {
background-position: 0% 100%;
}
.cth-01__link--center {
background-position: 50% 100%;
}
.cth-01__link--thru {
background-position: 100% 100%;
}
.cth-01__link--ltr:hover,
.cth-01__link--ltr:focus-visible,
.cth-01__link--center:hover,
.cth-01__link--center:focus-visible {
background-size: 100% .09em;
}
.cth-01__link--thru:hover,
.cth-01__link--thru:focus-visible {
background-position: 0% 100%;
background-size: 100% .09em;
}
.cth-01__link--native {
text-decoration-line: underline;
text-decoration-color: transparent;
text-decoration-thickness: .06em;
text-underline-offset: .32em;
text-decoration-skip-ink: auto;
transition: text-decoration-color .35s ease,text-underline-offset .35s cubic-bezier(.22,1,.36,1),text-decoration-thickness .35s ease;
}
.cth-01__link--native:hover,
.cth-01__link--native:focus-visible {
text-decoration-color: var(--acc);
text-underline-offset: .14em;
text-decoration-thickness: .1em;
}
.cth-01__prose {
font-size: 15px;
line-height: 1.75;
color: var(--mut);
max-width: 58ch;
}
.cth-01__prose a {
color: var(--ink);
font-weight: 650;
text-decoration: none;
background-image: linear-gradient(color-mix(in oklch,var(--acc) 32%,transparent),color-mix(in oklch,var(--acc) 32%,transparent));
background-repeat: no-repeat;
background-position: 0 88%;
background-size: 100% .14em;
transition: background-size .3s ease;
}
.cth-01__prose a:hover,
.cth-01__prose a:focus-visible {
background-size: 100% .6em;
}
.cth-01__prose a:focus-visible {
outline: 2px solid var(--acc);
outline-offset: 3px;
}
.cth-01__hint {
font-size: 12.5px;
color: var(--mut);
}
.cth-01__hint kbd {
font-family: ui-monospace,Menlo,monospace;
font-size: 11px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 2px 6px;
background: oklch(1 0 0);
}
.cth-01__hint code {
font-family: ui-monospace,Menlo,monospace;
font-size: .92em;
background: oklch(0.93 0.01 95);
padding: 1px 5px;
border-radius: 4px;
}
@container cth01 (width < 560px) {
.cth-01__row {
grid-template-columns: 1fr;
}
.cth-01__chip {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cth-01 * {
transition-duration: .01ms !important;
}
}
```How this works
Forget border-bottom (it jumps layout) and skip ::after (it breaks on wrapped inline links). Paint the underline as a background gradient and animate its size — the glyphs never move, and a link that wraps across two lines underlines both:
.link{ background-image:linear-gradient(var(--acc),var(--acc));
background-repeat:no-repeat;
background-size:0% .09em; /* invisible line, em-scaled */
background-position:0% 100%; /* anchored bottom-left */
transition:background-size .45s cubic-bezier(.22,1,.36,1); }
.link:hover,
.link:focus-visible{ background-size:100% .09em; }The anchor point is the whole trick. background-position:0% 100% grows the line from the left; 50% 100% blooms it from the center; and the slide-through variant parks the anchor at 100% 100% then swaps it to 0% 100% inside the hover rule — position isn't in the transition list, so the swap is instant and invisible while the size tween makes the line enter from the left and, on mouse-out, exit right. One property animated, three personalities.
The fourth variant is the 2026-native path: real text-decoration-line:underline with text-decoration-color fading in while text-underline-offset tightens from .32em to .14em — the line visibly docks onto the text. It respects descenders via text-decoration-skip-ink, something no gradient can do. Sizing everything in em means the same class works on a 13px footer link and a 38px menu item without retuning.
Make it yours
- Line weight and gap are the two knobs: the
.09emin background-size is the thickness; addpadding-bottom:.15emto push the line below descenders. - Highlighter mode: raise the height from
.09emto.55emand drop the gradient to 35% opacity with color-mix — the underline becomes a marker swipe (the inline prose links in this demo do exactly that). - Two-tone draw: stack two gradients with offset background-positions and stagger their transitions 80ms apart for a chased, hand-drawn feel.
- Speed discipline: 300–500ms with an ease-out feels editorial; under 200ms reads as a state flip, not an animation.
Gotchas — read before shipping
- display:inline-block on a link kills multi-line wrapping AND gradient underlines across breaks — keep links display:inline and let background-repeat handle wrapped lines (that's the point of this technique).
- transition:all also tweens the background-position swap in the slide-through variant, breaking the exit illusion — transition background-size only.
- text-decoration-thickness/offset transitions need Chrome 117+/Safari 16.4+; older engines snap between states (still legible, just not animated) — fine to ship, know what degrades.
- A 2px hairline on a 44px target is a thin affordance for touch — pair underlines with :focus-visible outlines (this demo does) so keyboard users get more than a fading line.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
Gradient-underline variants are 2017-era CSS and run anywhere; the floor comes from oklch()/color-mix() tokens (swap for hex to go older). Variant 04's animated text-underline-offset needs Chrome 117+/Safari 16.4+; it degrades to an instant swap elsewhere.