203

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.

Preview
  • 35,317
  • 10
  • 92
  • 112
Mala
  • 14,178
  • 25
  • 88
  • 119
  • why is the size change a problem? Is it messing up the layout somehow? – Chaulky Apr 15 '11 at 23:36
  • I think web programming question are best asked on Stack Overflow. Maybe a mod will migrate this there. – Ciaran Apr 16 '11 at 02: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 Apr 17 '11 at 17:25
  • http://doejo.com/blog/css-jquery-navigation-with-menu-items-enlarging-on-hover-without-shifting-adjacent-elements is one solution I've found that does exactly what John and Blowski were talking about jscript. – thebulfrog Jul 13 '11 at 17:43
  • 3
    Possible duplicate of [Inline elements shifting when made bold on hover](http://stackoverflow.com/questions/556153/inline-elements-shifting-when-made-bold-on-hover) – Preview Feb 16 '17 at 17:50
  • See https://stackoverflow.com/a/73028356/785194 below for a JavaScript solution with clean rendering and no menu movement for both horizontal and vertical menus. Only 11 years too late... :) – EML Jul 18 '22 at 20:41

13 Answers13

224

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

Cerbrus
  • 70,800
  • 18
  • 132
  • 147
Thorgeir
  • 3,960
  • 3
  • 24
  • 20
  • 68
    I love this solution, although I recommend flipping that one to 1px 0px 0px. Looks a little more bold-like. – M. Herold Mar 28 '13 at 00:34
  • I needed a css-only solution and this is the answer. – JCasso Jun 27 '13 at 08:00
  • your a real genius! simplicity make it awsome! – lufi Jan 10 '14 at 19:59
  • 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;}` – Patrick Hammer Jan 08 '16 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 Dec 16 '16 at 22:14
  • Nice solution, but shadows aren't supported by IE 8. –  Sep 02 '17 at 07:38
  • meta discussion https://meta.stackoverflow.com/questions/364040/should-i-edit-an-answer-to-incorporate-a-popular-comment?cb=1 – Martin Smith Mar 03 '18 at 21:03
  • @M.Herold Yes, or repeating it like this makes it basically identical to bold: "text-shadow: 0px 0px 0px #000, 0px 0px 0px #000, 0px 0px 0px #000, 0px 0px 0px #000, 0px 0px 0px #000, 0px 0px 0px #000;" – Martin Zvarík Nov 24 '18 at 12:31
  • 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. – Nikolai Mavrenkov Nov 09 '20 at 14:24
182

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

Community
  • 1
  • 1
Slava Eremenko
  • 2,235
  • 1
  • 11
  • 9
58

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,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).

Adam Katz
  • 14,455
  • 5
  • 68
  • 83
27

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.

Reactgular
  • 52,335
  • 19
  • 158
  • 208
  • 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/](http://jsfiddle.net/dJcPn/50/) – robotik Sep 01 '15 at 09:24
  • Modern browsers now supports sub-pixel accuracy. So you can fine tune the spacing using `0.98px` or smaller fractions. – Reactgular Aug 03 '16 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](https://stackoverflow.com/a/46452396/519360) for a live demonstration. – Adam Katz Nov 03 '17 at 17:05
  • @AdamKatz I suspect font rendering has changed since this answer was posted, and it depends a lot on what font you're using. – Reactgular Nov 03 '17 at 23:45
20

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.

Oliver
  • 2,930
  • 1
  • 20
  • 24
  • 1
    Also works on Safari 13.0.5. Cleanest solution for me so far. – Lars Blumberg May 26 '20 at 12:46
  • 3
    This works in Firefox, but be aware that it can look really ugly, kind of blurry. – MightyPork Oct 20 '20 at 14:11
  • 1
    Not a good solution, the font becomes "rounder" and as @MightyPork mentioned... blurry. – codenamezero Nov 17 '20 at 16:53
  • 1
    Still not great support for this feature in 2022: https://caniuse.com/?search=-webkit-text-stroke-width – Jonathan Rys Jun 18 '22 at 17:13
  • Neat! I've added this to the demo in [my answer](https://stackoverflow.com/a/46452396/519360) 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 Sep 08 '22 at 20:27
8

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.

aardvarkk
  • 14,955
  • 7
  • 67
  • 96
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.

dannymcgee
  • 621
  • 2
  • 9
  • 16
  • This dependant on the exact text used, so doesn't work well as a general solution if one uses more than one button. – lodewykk Jul 07 '22 at 17:54
4

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).

JCasso
  • 5,423
  • 2
  • 28
  • 42
ajcw
  • 23,604
  • 6
  • 30
  • 47
  • 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 Apr 17 '11 at 17:23
  • Yes, that sounds like the best way to accommodate non-javascript users. – ajcw Apr 18 '11 at 07:43
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).

Dan Blows
  • 20,846
  • 10
  • 65
  • 96
0

You can implement this like amazon.com "Shop by department" hover menu. It uses wide div. You can create wide div and hide its right part

Andrus
  • 26,339
  • 60
  • 204
  • 378
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>
Erik
  • 11
  • 3
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';
}
EML
  • 9,619
  • 6
  • 46
  • 78
-1

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.

Davis Peixoto
  • 5,695
  • 2
  • 23
  • 35