Classic - any font, broad support
The most portable and visually pleasing solution would be to use text-shadow
(h/t to Thorgeir's answer with atorscho's comment) combined with -webkit-text-stroke-width
(when available. h/t to Oliver's answer).
li:hover { text-shadow: -0.06ex 0 0 currentColor, 0.06ex 0 0 currentColor; }
@supports (-webkit-text-stroke-width: 0.04ex) { /* 2017+, mobile 2022+ */
li:hover { text-shadow: -0.03ex 0 0 currentColor, 0.03ex 0 0 currentColor;
-webkit-text-stroke-width: 0.04ex; }
}
This puts tiny "shadows" in your font's current color on both sides of each character using units that will scale properly with font rendering. If there's browser support, that shadow is halved and we increase the width of the strokes used to draw the text. This looks a little cleaner since shadows are blocky without a blur radius and strokes are blurry at higher levels (see the demo below).
Warning: while px
values support decimal values, they won't look so great when the font size changes (e.g. the user scales the view with Ctrl++). Use relative values instead.
This answer uses fractions of ex
units since they scale with the font.
In ~most browser defaults*, expect 1ex
≈ 8px
and therefore 0.025ex
≈ 0.1px
.
Modern - variable fonts
There are now a few new variable fonts capable of things like changing font grade via font-variation-settings
. You need both browser support (good since 2018) and support in the specific font you're using (which is still rare).
@import url("https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght,GRAD@8..144,400,45;8..144,400,50;8..144,1000,0&family=Roboto+Serif:opsz,GRAD@8..144,71&display=swap");
li { font-family:Roboto Flex, sans-serif; }
/* Grade: Increase the typeface's relative weight/density */
@supports (font-variation-settings: 'GRAD' 150) {
li:hover { font-variation-settings: 'GRAD' 150; }
}
/* Text Shadow: Failover for pre-2018 browsers */
@supports not (font-variation-settings: 'GRAD' 150) {
li:hover { text-shadow: -0.06ex 0 0 currentColor, 0.06ex 0 0 currentColor; }
}
This loads a variable font and then, in browsers that support such fonts' settings, instructs the browser to render that font with a bold-level grade when hovered. The classic solution (without strokes since those are about as new as variable font support) is provided as a failover for older browsers, but as there's rather universal support for font grade since 2018, that should no longer be necessary.
For completeness (since this does affect rendered width), variable fonts also support analog weights (boldness), which contrasts with presribed weights from font-weight
. Using either method, you are beholden to the granularity of your font. While variable fonts that support the wght
variation allow a full spectrum of weights, most fonts either lack a bold variation or else have only one. Systems presented with the need to render a font as bold will do it themselves as needed, but only at one weight (detail and example here). Some non-variable fonts offer several weights, like Roboto, used in the demo below. Play with the slider in the demo to see the granularity difference.
Demo comparing six methods
(Don't be daunted by the large code block, that's mostly used to implement the interactive slider and compare all of the methods offered by answers to this question.)
@import url("https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900");
@import url("https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght,GRAD@8..144,400,45;8..144,400,50;8..144,1000,0&family=Roboto+Serif:opsz,GRAD@8..144,71&display=swap");
body { font-family: 'Roboto'; }
.v { font-family: 'Roboto Flex'; } /* variable! */
/* For parity w/ shadow, weight is 400+ (not 100+) & grade is 0+ (not -200+) */
li:hover { text-shadow: initial !important; font-weight: normal !important;
-webkit-text-stroke-width: 0 !important; }
.weight { font-weight: calc(var(--bold) * 500 + 400); }
.shadow, .grade { text-shadow: calc(var(--bold) * -0.06ex) 0 0 currentColor,
calc(var(--bold) * 0.06ex) 0 0 currentColor; }
ul[style*="--bold: 0;"] li { text-shadow:none; } /* none < zero */
.stroke { -webkit-text-stroke-width: calc(var(--bold) * 0.08ex); }
.strokshd { -webkit-text-stroke-width: calc(var(--bold) * 0.04ex);
text-shadow: calc(var(--bold) * -0.03ex) 0 0 currentColor,
calc(var(--bold) * 0.03ex) 0 0 currentColor; }
.after span { display:inline-block; font-weight: bold; } /* @SlavaEremenko */
.after:hover span { font-weight:normal; }
.after span::after { content: attr(title); font-weight: bold;
display: block; height: 0; overflow: hidden; }
.ltrsp { letter-spacing:0px; font-weight:bold; } /* @Reactgular */
.ltrsp:hover { letter-spacing:1px; }
@supports (font-variation-settings: 'GRAD' 150) { /* variable font support */
:hover { font-variation-settings: 'GRAD' 0 !important; }
.weight.v { font-weight: none !important;
font-variation-settings: 'wght' calc(var(--bold) * 500 + 400); }
.grade { text-shadow: none !important;
font-variation-settings: 'GRAD' calc(var(--bold) * 150); }
}
Boldness: <input type="range" value="0.5" min="0" max="1.5" step="0.01"
style="height: 1ex;"
onmousemove="document.getElementById('dynamic').style
.setProperty('--bold', this.value)">
<ul style="--bold: 0.5; margin:0;" id="dynamic">
<li class="" >MmmIii123 This tests regular weight/grade/shadow</li>
<li class="weight" >MmmIii123 This tests the slider (weight)</li>
<li class="weight v">MmmIii123 This tests the slider (weight, variable)</li>
<li class="grade v" >MmmIii123 This tests the slider (grade, variable)</li>
<li class="shadow" >MmmIii123 This tests the slider (shadow)</li>
<li class="stroke" >MmmIii123 This tests the slider (stroke)</li>
<li class="strokshd">MmmIii123 This tests the slider (50/50 stroke/shadow)</li>
<li class="after"><span title="MmmIii123 This tests [title]"
>MmmIii123 This tests [title]</span> (@SlavaEremenko)</li>
<li class="ltrsp" >MmmIii123 This tests ltrsp (@Reactgular)</li>
</ul>
Hover over the rendered lines to see how they differ from standard text. (This reverses the question's intent to make hovered text bold so we can more easily compare the different methods.) Move the Boldness slider around (for the slider-controlled entries) or alter your browser's zoom level (Ctrl++ and Ctrl+-) to see how they vary.
Note how the (non-variable) weight adjusts in four discrete steps while the variable weight is continuous.
I added two other solutions here for comparison: @Reactgular's letter spacing trick, which doesn't work so well since it involves guessing font width ranges, and @SlavaEremenko's ::after
drawing trick, which leaves awkward extra space so the bold text can expand without nudging neighboring text items (I put the attribution after the bold text so you can see how it does not move).