225

I have a horizontal navigation menu, which is basically just a <ul> with the elements set side-by-side. I do not define width, but simply use padding, because I would like the widths to be defined by the width of the menu item. I bold the currently-selected item.

The trouble is that in bolding, the word becomes slightly wider, which causes the rest of the elements to shift slightly to the left or right. Is there a clever way to prevent this from happening? Something along the lines of telling the padding to ignore the extra width caused by the bolding? My first thought was to simply subtract a few pixels from the padding of the "active" element, but this amount varies.

If possible I'd like to avoid setting a static width on each entry and then centering as opposed to the padding solution I currently have, in order to make future changes to the entries simple.

6
  • why is the size change a problem? Is it messing up the layout somehow?
    – Chaulky
    Commented Apr 15, 2011 at 23:36
  • I think web programming question are best asked on Stack Overflow. Maybe a mod will migrate this there.
    – Ciaran
    Commented Apr 16, 2011 at 2:01
  • 5
    Chaulky: because there are few things I hate more, layout-wise, than when buttons you're supposed to try to click jump around
    – Mala
    Commented Apr 17, 2011 at 17:25
  • doejo.com/blog/… is one solution I've found that does exactly what John and Blowski were talking about jscript.
    – thebulfrog
    Commented Jul 13, 2011 at 17:43
  • 3
    Possible duplicate of Inline elements shifting when made bold on hover
    – Preview
    Commented Feb 16, 2017 at 17:50

12 Answers 12

237

I had the same problem, but got a similar effect with a little compromise, I used text-shadow instead.

li:hover {text-shadow:0px 0px 1px black;}

Here's a working example:

body {
  font-family: segoe ui;
}

ul li {
  display: inline-block;
  border-left: 1px solid silver;
  padding: 5px
}

.textshadow :hover {
  text-shadow: 0px 0px 1px black;
}

.textshadow-alt :hover {
  text-shadow: 1px 0px 0px black;
}

.bold :hover {
  font-weight: bold;
}
<ul class="textshadow">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
  <li><code>text-shadow: 0px 0px 1px black;</code></li>
</ul>

<ul class="textshadow-alt">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
  <li><code>text-shadow: 1px 0px 0px black;</code></li>
</ul>

<ul class="bold">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
  <li><code>font-weight: bold;</code></li>
</ul>

jsfiddle example

9
  • 70
    I love this solution, although I recommend flipping that one to 1px 0px 0px. Looks a little more bold-like.
    – M. Herold
    Commented Mar 28, 2013 at 0:34
  • I needed a css-only solution and this is the answer.
    – JCasso
    Commented Jun 27, 2013 at 8:00
  • 3
    Only adding li:hover {text-shadow: 1px 0 0 black;} resulted in text with too little spacing between the letters. To improve that again, we also set li {letter-spacing: 1px;} Commented Jan 8, 2016 at 15:40
  • 9
    I used -0.5px 0 #fff, 0.5px 0 #fff, because we could see the little gap between the text itself and the shadow. And also when text is bold, it adds weight to both sides.
    – atorscho
    Commented Dec 16, 2016 at 22:14
  • 5
    I don't think this is a good solution, bold font is not just wider font, most fonts have special letter forms for bold weight. Commented Nov 9, 2020 at 14:24
201

The best working solution using ::after

HTML

<li title="EXAMPLE TEXT">
  EXAMPLE TEXT
</li>

CSS

li::after {
  display: block;
  content: attr(title);
  font-weight: bold;
  height: 1px;
  color: transparent;
  overflow: hidden;
  visibility: hidden;
}

It adds an invisible pseudo-element with width of bold text, sourced by title attribute.

The text-shadow solution looks unnatural on Mac and doesn't utilize all the beauty that text rendering on Mac offers.. :)

http://jsfiddle.net/85LbG/

Credit: https://stackoverflow.com/a/20249560/5061744

15
  • 13
    This is the most reliable and clean solution, it simply reserves space for bold text in the element. In my case, the additional pseudo-element caused change of height. Adding negative margin-top to the ::after block solved that. Commented May 20, 2016 at 9:07
  • 4
    This solution is great! I used JS to add a "data-content" attribute that contains the element's text so I could avoid changing the HTML. Commented Feb 8, 2017 at 18:57
  • 4
    Awesome. This should be the best answer. Note that if different element than li you may have to use display:block; on the :after parent element.
    – Blackbam
    Commented Dec 16, 2017 at 18:29
  • 3
    How is this a proper solution when you are forced to use title and it doesn't even work if you don't have a title attribute... Commented Jul 17, 2020 at 19:33
  • 3
    Best solution. One addition: it's safe to use height: 0;
    – Tigran
    Commented Oct 5, 2021 at 13:00
63

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 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 1ex8px and therefore 0.025ex0.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,[email protected],400,45;8..144,400,50;8..144,1000,0&family=Roboto+Serif:opsz,[email protected],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,[email protected],400,45;8..144,400,50;8..144,1000,0&family=Roboto+Serif:opsz,[email protected],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).

4
  • 1
    So simple, so beautiful.
    – Randy Hall
    Commented Feb 21, 2019 at 0:06
  • This has issue with Firefox 82 (as I type) and Safari though. It seem using 0.5px is a better option. Commented Nov 17, 2020 at 16:48
  • 2
    You could use currentColor instead of black or any specific colour (although I haven't tested across many browsers) Commented Feb 6, 2021 at 23:10
  • 1
    @DavidGilbertson – Nice call. currentColor is supported universally, so I added it to the code.
    – Adam Katz
    Commented Aug 5, 2021 at 22:32
28

I found that most fonts are the same size when you adjust letter spacing by 1px.

a {
   letter-spacing: 1px;
}

a:hover {
   font-weight: bold;
   letter-spacing: 0px;
}

While this does change the regular font so that each letter has an extra pixel spacing. For menus the titles are so short it doesn't present as a problem.

4
  • 3
    this is the best and most underrated solution, although I changed it so that it starts out with 0 and goes to negative letter spacing when bold. also you have to fine-tune it for each font and font-size. I also updated @Thorgeir's fiddle to include this (as the 2nd solution) jsfiddle.net/dJcPn/50/
    – robotik
    Commented Sep 1, 2015 at 9:24
  • Modern browsers now supports sub-pixel accuracy. So you can fine tune the spacing using 0.98px or smaller fractions.
    – Reactgular
    Commented Aug 3, 2016 at 23:10
  • This looks a bit odd since the spacing algorithm isn't exactly the same (some letters dance around a bit) and, more importantly, this does not work when fonts are scaled, even if you convert the 1px to relative values like 0.025ex or 0.0125ex. See my answer for a live demonstration.
    – Adam Katz
    Commented Nov 3, 2017 at 17:05
  • 1
    @AdamKatz I suspect font rendering has changed since this answer was posted, and it depends a lot on what font you're using.
    – Reactgular
    Commented Nov 3, 2017 at 23:45
24

For a more up-to-date answer, you can use -webkit-text-stroke-width:

.element {
  font-weight: normal;
}

.element:hover {
  -webkit-text-stroke-width: 1px;
  -webkit-text-stroke-color: black;
}

This avoids any pseudo-elements (which is a plus for screen readers) and text-shadows (which looks messy and can still create a slight 'jump' effect) or setting any fixed widths (which can be impractical).

It also allows you to set an element to be bolder than 1px (theoretically, you can make a font as bold as you like and could also be a shoddy-ish workout for creating a bold version of a font that doesn't have a bold variant, like custom fonts (edit: variable fonts depreciate this suggestion). Though this should be avoided as it will probably make some fonts appear scratchy and jagged)

I this definitely works in Edge, Firefox, Chrome and Opera (at time of posting) and in Safari (edit: @Lars Blumberg thanks for confirming that). It does NOT work in IE11 or below.

Also note, it uses the -webkit prefix, so this is not standard and support may be dropped in the future, so don't rely on this is bold is really important - it's best to avoid this technique unless it's merely aesthetic.

5
  • 1
    Also works on Safari 13.0.5. Cleanest solution for me so far. Commented May 26, 2020 at 12:46
  • 5
    This works in Firefox, but be aware that it can look really ugly, kind of blurry.
    – MightyPork
    Commented Oct 20, 2020 at 14:11
  • 1
    Not a good solution, the font becomes "rounder" and as @MightyPork mentioned... blurry. Commented Nov 17, 2020 at 16:53
  • 1
    Still not great support for this feature in 2022: caniuse.com/?search=-webkit-text-stroke-width Commented Jun 18, 2022 at 17:13
  • Neat! I've added this to the demo in my answer and (after detecting support) hybridize my text-shadow solution with this one to reduce the blur noted by @MightyPork. The most up-to-date solution would be to use variable font grades, which have better browser support but require a custom font (learn more in my answer).
    – Adam Katz
    Commented Sep 8, 2022 at 20:27
11

I would highly recommend trying a uniwidth font for this use case.

They're still proportional typefaces (as opposed to monospace), but they occupy the same size across different font weights. No CSS, JS, or fancy hacks are required to keep the size constant. It's baked right into the typeface.

Here's an example from this excellent article comparing the proportional font IBM Plex Sans to the uniwidth Recursive.

comparing proportional to uniwidth font

Free uniwidth fonts you can try are:

There are also many non-free options linked to via the aforementioned article.

5

Unfortunately the only way to avoid the width changing when the text is bold is to define the width of the list item, however as you stated doing this manually is time consuming and not scalable.

The only thing I can think of is using some javascript that calculates the width of the tab before it is bold, and then applies the width at the same time the bold is required (either when you hover or click).

2
  • hm, I will experiment with this, although i want it to at least look reasonable (ie. bolded) for non JS users. Maybe I can send it bolded, use JSto unbold it, calculate the width, and then bold it again? Or is that just silly?
    – Mala
    Commented Apr 17, 2011 at 17:23
  • Yes, that sounds like the best way to accommodate non-javascript users.
    – ajcw
    Commented Apr 18, 2011 at 7:43
5

This is a very old question, but I'm revisiting it because I had this problem in an app I'm developing and found all of the answers here wanting.

(Skip this paragraph for the TL;DR...) I'm using the Gotham webfont from cloud.typography.com, and I have buttons which start hollow (with a white border/text and a transparent background) and acquire a background color on hover. I found that some of the background colors I was using didn't contrast well with the white text, so I wanted to change the text to black for those buttons, but — whether because of a visual trick or common anti-aliasing methods — dark text on a light background always appears to be lighter weight than white text on a dark background. I found that increasing the weight from 400 to 500 for the dark text maintained almost exactly the same "visual" weight. However, it was increasing the button width by a tiny amount — a fraction of a pixel — but it was enough to make the buttons appear to "jitter" slightly, which I wanted to get rid of.

Solution:

Obviously, this is a really finicky problem so it required a finicky solution. Ultimately I used a negative letter-spacing on the bolder text as cgTag recommended above, but 1px would have been way overkill, so I just calculated exactly the width I would need.

By inspecting the button in Chrome devtools, I found that the default width of my button was 165.47px, and 165.69px on hover, a difference of 0.22px. The button had 9 characters, so:

0.22 / 9 = 0.024444px

By converting that to em units I could make the adjustment font-size agnostic. My button was using a font size of 16px, so:

0.024444 / 16 = 0.001527em

So for my particular font, the following CSS keeps the buttons exactly the same width on hover:

.btn {
  font-weight: 400;
}

.btn:hover {
  font-weight: 500;
  letter-spacing: -0.001527em;
}

With a little testing and using the formula above, you can find exactly the right letter-spacing value for your situation, and it should work regardless of font size.

The one caveat is that different browsers use slightly different sub-pixel calculations, so if you're aiming for this OCD level of sub-pixel-perfect precision, you'll need to repeat the testing and set a different value for each browser. Browser-targeted CSS styles are generally frowned upon, for good reason, but I think this is one use case where it's the only option that makes sense.

1
  • This dependant on the exact text used, so doesn't work well as a general solution if one uses more than one button.
    – lodewykk
    Commented Jul 7, 2022 at 17:54
2

Use JavaScript to set a fixed width of the li based on the unbolded content, then bold the content by applying a style to the <a> tag (or add a span if the <li> doesn't have any children).

2
  • Oops - sorry for repeating :$
    – Dan Blows
    Commented Apr 16, 2011 at 13:11
  • @Mala Did you find a solution? Funnily enough, I have a similar problem.
    – Dan Blows
    Commented Apr 28, 2011 at 22:47
0

UPDATE: Had to use the B tag for the title because in IE11 the pseudo class i:after didn't show when i had visibility:hidden.

In my case I want to align a (custom designed) input checkbox/radio with label text where the text goes bold when the input is checked.

The solution provided here did not work for me in Chrome. The vertical alignment of input and label got messed up with the :after psuedo class and -margins did not fix this.

Here is a fix where you don't get trouble with vertical alignments.

/* checkbox and radiobutton */
label
{
    position: relative;
    display: inline-block;
    padding-left: 30px;
    line-height: 28px;
}

/* reserve space of bold text so that the container-size remains the same when the label text is set to bold when checked. */
label > input + b + i
{
    font-weight: bold;
    font-style: normal;
    visibility: hidden;
}

label > input:checked + b + i
{
    visibility: visible;
}

/* set title attribute of label > b */
label > input + b:after
{
    display: block;
    content: attr(title);
    font-weight: normal;
    position: absolute;
    left: 30px;
    top: -2px;
    visibility: visible;
}

label > input:checked + b:after
{
    display: none;
}

label > input[type="radio"],
label > input[type="checkbox"]
{
    position: absolute;
    visibility: hidden;
    left: 0px;
    margin: 0px;
    top: 50%;
    transform: translateY(-50%);
}

    label > input[type="radio"] + b,
    label > input[type="checkbox"]  + b
    {
        display: block;
        position: absolute;
        left: 0px;
        margin: 0px;
        top: 50%;
        transform: translateY(-50%);
        width: 24px;
        height: 24px;
        background-color: #00a1a6;
        border-radius: 3px;
    }

    label > input[type="radio"] + b
    {
        border-radius: 50%;
    }

    label > input:checked + b:before
    {
        display: inline-block;
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%) rotate(45deg);
        content: '';
        border-width: 0px 3px 3px 0px;
        border-style: solid;
        border-color: #fff;
        width: 4px;
        height: 8px;
        border-radius: 0px;
    }

    label > input[type="checkbox"]:checked + b:before
    {
        transform: translate(-50%, -60%) rotate(45deg);
    }

    label > input[type="radio"]:checked + b:before
    {
        border-width: 0px;
        border-radius: 50%;
        width: 8px;
        height: 8px;
    }
<label><input checked="checked" type="checkbox"/><b title="Male"></b><i>Male</i></label>
<label><input type="checkbox"/><b title="Female"></b><i>Female</i></label>

0

I spent a couple of hours going through the answers here, but was unhappy with the rendering quality of the text-shadow and -webkit-text-stroke-width solutions, the content:attr, and so on.

So, here's a JS alternative that displays a clean bolded and enlarged font when hovered or clicked. The vertical menu version is on Codeply here, and the horizontal version is here. In both cases, comment out the call to fixElementSize to see the difference: with the call, the menu item positioning remains rock-solid.

The hard work is done by this function (substitute 'height' for 'width' for the horizontal version), but it requires box-sizing:border-box and display:block:

/**
 * Set all anchor tags below 'myclass' to have the same fixed height.  Note
 * that 'getBoundingClientRect().height' gives the same integral result as
 * 'offsetHeight' for display:block
 */
function fixElementSize(myclass) {
    var atag1 = document.querySelector(myclass + ' a');
    var height = atag1.offsetHeight;
    var nlist = document.querySelectorAll(myclass + ' a');
    for (let i = 0; i < nlist.length; i++)
        nlist[i].style.height = height + 'px';
}
-2

Interesting question. I suppose you are using float, right?

Well, I don't know any technique you can use to get rid of this font enlarging, hence they will try to fit in the minimum width required - and varying font thickness will change this value.

The unique solution I know to avoid this changing is one you said you don't want: setting fixed sizes to li's.

Not the answer you're looking for? Browse other questions tagged or ask your own question.