18

I am working on a personal project with NextJs and TailwindCSS.

upon finishing the project I used a private navigator to see my progress, but it seems that the stroke is not working as it should, I encounter this in all browsers except Chrome.

Here is what i get :

enter image description here

Here is the desired behavior :

enter image description here

Code:

<div className="outline-title text-white pb-2 text-5xl font-bold text-center mb-12 mt-8">
      Values &amp; Process
</div>

Css:

.outline-title {
  color: rgba(0, 0, 0, 0);
  -webkit-text-stroke: 2px black;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

Can someone explain or help to fix this.

Browser compatibility: enter image description here

2
  • can you let me know the font which you have used? I have tried in Chrome and Safari, it's working fine codepen.io/pplcallmesatz/pen/oNeyQrv Commented Nov 10, 2021 at 5:09
  • font-family: "Calibre", "Inter", "San Francisco", "SF Pro Text", -apple-system, system-ui, sans-serif; Commented Nov 10, 2021 at 8:32

9 Answers 9

28
+50

-webkit-text-stroke doesn't work well with variable fonts

Quickfix/Update 2024: apply paint-order to HTML text

All credits to HyoukJoon Lee's answer here: "CSS Font Border?". Admittedly, it is not quite clear from the W3C specs why we can apply the SVG paint-order property to HTML text elements as well as described on mdn docs. We won't need duplicated text via pseudo-elements.
All major rendering engines (Firefox/gecko, Chromium/blink, Safari/webkit) seem to support this property flawlessly.

@font-face {
  font-family: 'Roboto Flex';
  font-style: normal;
  font-weight: 100 1000;
  font-stretch: 0% 200%;
  src: url(https://fonts.gstatic.com/s/robotoflex/v9/NaNeepOXO_NexZs0b5QrzlOHb8wCikXpYqmZsWI-__OGfttPZktqc2VdZ80KvCLZaPcSBZtOx2MifRuWR28sPJtUMbsFEK6cRrleUx9Xgbm3WLHa_F4Ep4Fm0PN19Ik5Dntczx0wZGzhPlL1YNMYKbv9_1IQXOw7AiUJVXpRJ6cXW4O8TNGoXjC79QRyaLshNDUf9-EmFw.woff2) format('woff2');
}

body {
  font-family: 'Roboto Flex';
  font-weight: 500;
  font-size: 10vmin;
  margin: 2em;
  background-color: #999;
}

h3 {
  font-size: 16px;
  color: #fff
}

h1 {
  -webkit-text-stroke: 0.02em black;
  color: #fff;
  font-stretch: 0%;
  font-weight: 200;
}


/* render stroke behind text-fill color */

.outline {
  -webkit-text-stroke: 0.04em black;
  paint-order: stroke fill;
}
<h3>Stroked - showing geometry of the font</h3>
<h1>AVATAR</h1>

<h3>Outlined - stroke behind fill</h3>
<h1 class="outline">AVATAR</h1>

Reasons for this rendering: Anatomy of a variable font

The reason for these inner outlines is based on the structure of some variable fonts.

'Traditional' fonts (so before variable fonts) – only contained an outline shape and maybe a counter shape e.g the cut out inner 'hole' of a lowercase e glyph.

Otherwise you would have encountered undesired even/odd issues resulting in excluded shapes caused by overlapping path areas.

Applying this construction method, you will never see any overlap of shapes. You could imagine them as rather 'merged down' compound paths. Counter shapes like the aforementioned hole were based on simple rules like a counterclockwise path directions – btw. you might still encounter this concept in svg-clipping paths - not perfectly rendering in some browsers).

enter image description here

Variable fonts however allow a segemented/overlapping construction of glyphs/characters to facilitate the interpolation between different font weights and widths. See also "Microsoft typography: Comparison of 'glyf', 'CFF ' and CFF2 tables"

Obviously webkit-text-stroke outlines the exact bézier anatomy of a glyph/character resulting in undesired outlines for every glyph component.

This is not per se an issue of variable fonts, since weight and width interpolations has been used in type design for at least 25 years. So this quirky rendering issue depends on the used font – a lot of classic/older fonts compiled to the newer variable font format will still rely on the old school aproach (avoiding any overlap).

Workaround: Search for static version of a font family

In your case you can still find alternative non-variable versions of this font e.g on github or google web fonts helper

/* latin */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  src: url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ90A2N58.woff2)
    format("woff2");
}

@font-face {
  font-family: 'InterStatic';
  font-style: normal;
  font-weight: 700;
  src: url(https://cdn.jsdelivr.net/gh/rsms/inter@master/docs/font-files/InterDisplay-Bold.woff2) format('woff2');
}


body {
  font-family: "Inter";
  font-size: 5em;
  color: #fff;
  -webkit-text-stroke: 0.02em red;
}

.interStatic{
    font-family: 'InterStatic';
}
<h1>Values & Process</h1>
<h1 class="interStatic">Values & Process</h1>

Other issues with -webkit-text-stroke

  • Inconsistent rendering:Firefox renders the stroke with rounded corners
  • weird "kinks and tips" on sharp angles

text-stroke issues

  1. Firefox - Roboto Flex(variable font); 2. Chromium - Roboto Flex(variable font); 3. Chromium - Roboto (static font);

Example snippet: test -webkit-text-stroke rendering

addOutlineTextData();

function addOutlineTextData() {
  let textOutline = document.querySelectorAll(".textOutlined");
  textOutline.forEach((text) => {
    text.dataset.content = text.textContent;
  });
}

let root = document.querySelector(':root');


sampleText.addEventListener("input", (e) => {
  let sampleText = e.currentTarget.textContent;
  let textOutline = document.querySelectorAll(".textOutlined");
  textOutline.forEach((text) => {
    text.textContent = sampleText;
    text.dataset.content = sampleText;
  });
});

strokeWidth.addEventListener("input", (e) => {
  let width = +e.currentTarget.value;
  strokeWidthVal.textContent = width + 'em'
  root.style.setProperty("--strokeWidth", width + "em");
});

fontWeight.addEventListener("input", (e) => {
  let weight = +e.currentTarget.value;
  fontWeightVal.textContent = weight;
  document.body.style.fontWeight = weight;
});

useStatic.addEventListener("input", (e) => {
  let useNonVF = useStatic.checked ? true : false;
  if (useNonVF) {
    document.body.style.fontFamily = 'Roboto';
  } else {
    document.body.style.fontFamily = 'Roboto Flex';
  }
});
@font-face {
  font-family: 'Roboto Flex';
  font-style: normal;
  font-weight: 100 1000;
  font-stretch: 0% 200%;
  src: url(https://fonts.gstatic.com/s/robotoflex/v9/NaNeepOXO_NexZs0b5QrzlOHb8wCikXpYqmZsWI-__OGfttPZktqc2VdZ80KvCLZaPcSBZtOx2MifRuWR28sPJtUMbsFEK6cRrleUx9Xgbm3WLHa_F4Ep4Fm0PN19Ik5Dntczx0wZGzhPlL1YNMYKbv9_1IQXOw7AiUJVXpRJ6cXW4O8TNGoXjC79QRyaLshNDUf9-EmFw.woff2) format('woff2');
}

body {
  font-family: 'Roboto Flex';
  font-weight: 500;
  margin: 2em;
}

.p,
p {
  margin: 0;
  font-size: 10vw;
}

.label {
  font-weight: 500!important;
  font-size: 15px;
}

.resize {
  resize: both;
  border: 1px solid #ccc;
  overflow: auto;
  padding: 1em;
  width: 40%;
}

:root {
  --textOutline: #000;
  --strokeWidth: 0.1em;
}

.stroke {
  -webkit-text-stroke: var(--strokeWidth) var(--textOutline);
  color: #fff
}

.textOutlined {
  position: relative;
  color: #fff;
}

.textOutlined:before {
  content: attr(data-content);
  position: absolute;
  z-index: -1;
  color: #fff;
  top: 0;
  left: 0;
  -webkit-text-stroke: var(--strokeWidth) var(--textOutline);
  display: block;
  width: 100%;
}
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900" rel="stylesheet">
<p class="label">stroke width<input id="strokeWidth" type="range" value="0.3" min='0.01' max="0.5" step="0.001"><span id="strokeWidthVal">0.25em</span> | font-weight<input id="fontWeight" type="range" value="100" min='100' max="900" step="10"><span id="fontWeightVal">100</span>
  <label><input id="useStatic" type="checkbox">Use static Roboto</label><br><br>
</p>


<div id="sampleText" class="stroke p" contenteditable>AVATAR last <br>Airbender</div>
<p class="label">Outline via pseudo element in background</p>
<div class="resize">
  <p class="textOutlined">AVATAR last Airbender
  </p>
</div>

However, these rendering issues are rare as long as your stroke-width is not significantly larger than ~0.1em (or 10% of your current font-size).

See also "Outline effect to text"

Alternative: JavaScript based SVG replacement

We're basically querying specified HTML elements and rebuild them as SVG <text> elements. The main advantage of this approach: we have more fine-grained control over the text-stroke rendering due to SVGs paint-order attribute – update:this also works for HTML elements. Besides, we get a more predictable corner rounding via stroke-linecap and stroke-linejoin:

btnConvert.onclick = () => {
  htmlText2SvgText();
}

/* when loaded instantly - wait for all fonts to be loaded
(async () => {
    await document.fonts.ready;
    htmlText2SvgText();
})();
*/



function htmlText2SvgText(selector = ".html2SvgText") {
  let textEls = document.querySelectorAll(selector);

  // quit if already converted
  let processedEls = document.querySelectorAll('.svgTxt');
  if (processedEls.length) return;

  textEls.forEach(textEl => {

    // get text nodes
    let textNodes = getTextNodesInEL(textEl);

    textNodes.forEach(textNode => {

      let textParent = textNode.parentElement;

      // split to words to ensure line wrapping
      let words = textNode.textContent.split(' ').filter(Boolean);

      // get font style properties from parent
      let style = window.getComputedStyle(textParent)
      let {
        webkitTextStrokeWidth,
        webkitTextStrokeColor,
        fontSize,
        fontStretch,
        fontStyle,
        color,
        letterSpacing,
        wordSpacing,
      } = style;


      /**
       * convert property values 
       * to relative em based values 
       * used for SVG text conversion
       */
      let strokeWidthRel = Math.ceil(100 / parseFloat(fontSize) * parseFloat(webkitTextStrokeWidth) * 2)
      let letterSpacingRel = letterSpacing && letterSpacing !== 'normal' ? (parseFloat(letterSpacing) / parseFloat(fontSize)).toFixed(3) : 0;
      let wordSpacingRel = wordSpacing && wordSpacing != 'normal' ? +(parseFloat(wordSpacing) / parseFloat(fontSize)).toFixed(3) : 0;


      // adjust letter and word spacing for parent element 
      if (letterSpacingRel || wordSpacingRel) textParent.setAttribute('style', `letter-spacing:0em; word-spacing:${(letterSpacingRel) * words.length + wordSpacingRel}em`);


      // loop words and replace them with SVG 
      words.forEach((word, i) => {

        // add space in between word SVGs
        let space = i < words.length - 1 ? ' ' : '';
        let svg = new DOMParser().parseFromString(
          `<svg class="svgTxt" viewBox="0 0 100 100" style="overflow:visible; display:inline-block;height:1em; width:auto;line-height:1em;margin-top: -100px;">
                        <text class="svgTxt-text" x="0" y="100" 
                        font-size="100" 
                        fill="currentColor" 
                        style="font-kerning:normal; font-stretch: ${fontStretch}"
                        stroke="${webkitTextStrokeColor}" 
                        stroke-width="${strokeWidthRel}"
                        letter-spacing="${letterSpacingRel}em" 
                        paint-order="stroke" 
                        stroke-linecap="round"
                        stroke-linejoin="round">${word.trim()}</text>
                    </svg>`,
          'text/html'
        ).querySelector('svg');

        //textParent.insertAdjacentHTML('afterbegin', svg);
        textParent.insertBefore(svg, textNode);

        if (i < words.length - 1) {}
        // add spaces
        let spaceNode = document.createTextNode(' ');
        textParent.insertBefore(spaceNode, svg.nextSibling);


        //let svgEls = textParent.querySelectorAll('svg');
        let textSVG = svg.querySelector('text')

        //get bbox
        let {
          x,
          width
        } = textSVG.getBBox();

        // shorten by letter spacing value
        let shorten = 100 * letterSpacingRel;
        shorten = letterSpacingRel + wordSpacingRel * 2;
        svg.setAttribute('viewBox', [Math.floor(x), 0, Math.floor(width) - shorten, 100].join(' '));

        ({
          x,
          width
        } = textSVG.getBBox());
        svg.setAttribute('viewBox', [Math.floor(x), 0, Math.floor(width) - shorten, 100].join(' '));

      })

      // erase currenet text content
      textNode.remove();

    })
  })
}


// text helpers
function getTextNodesInEL(el) {
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
  const nodes = [];
  while (walker.nextNode()) {
    nodes.push(walker.currentNode);
  }
  return nodes;
}
* {
  box-sizing: border-box;
}

@font-face {
  font-family: 'Roboto Flex';
  font-style: oblique 0deg 10deg;
  font-weight: 100 1000;
  font-stretch: 25% 151%;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/robotoflex/v26/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}


/* prevent faux italicizing */

em {
  font-variation-settings: 'slnt' -10;
  font-style: normal;
}

body {
  font-family: "Roboto", sans-serif;
  font-family: "Roboto Flex", sans-serif;
}

.resize {
  font-size: 5vw;
  letter-spacing: 0.01em;
  font-stretch: 110%;
  overflow: auto;
  padding: 0.1em;
  border: 1px solid #ccc;
  width: 100%;
  resize: both;
}

h1 {
  font-size: 3em;
  line-height: 1.1em;
  font-weight: 400;
  font-stretch: 30%;
  text-transform: uppercase;
  letter-spacing: 0;
  margin: 0;
  text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}

strong {
  font-stretch: 150%;
}

.stroked-text {
  -webkit-text-stroke: 2px darkred;
  color: #fff;
}
<p>
  <button id="btnConvert">convert HTML els to SVG</button>
</p>

<div class="resize">
  <h1 class="html2SvgText stroked-text">Franz Kafka <span style="-webkit-text-stroke-color:green; -webkit-text-stroke-width:3px;font-stretch:75%">The
                    Metamorphosis</span></h1>

  <p>One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a
    <strong class="html2SvgText"><em><span class="stroked-text">horrible vermin.</span></em></strong> He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections.
    The bedding was hardly able to cover it and seemed ready to slide off any moment.
  </p>

</div>

Caveats

  • Since we're splitting each word we'll encounter tiny layout shifts.
  • width related changes (e.g letter-spacing) wont't auto update as we would need to recalculate the SVGs' viewBox again

Features:

  • all text remains selectable
  • formatting is retained/copied from parent text elements
  • text remains responsive e.g line/word wraps work on resize as well as font-size changes
Sign up to request clarification or add additional context in comments.

1 Comment

This is a very comprehensive answer. I will add that if the text fill color is transparent, then paint order doesn't necessarily fix it. I specifically has issues on mobile, with Chrome and Opera, and the use of a static font was the only solution. Be sure to link directly to the individual font file with weight specified, like the InterStatic example above.
9

Due to browser compatibility -webkit-text-stroke will not support in a few browsers. You can achieve the outline effect by using shadow.

Hope this works!

.outline-title {
font-family: sans-serif;
   color: white;
   text-shadow:
       1px 1px 0 #000,
     -1px -1px 0 #000,  
      1px -1px 0 #000,
      -1px 1px 0 #000;
      font-size: 50px;
}
<div class="outline-title text-white pb-2 text-5xl font-bold text-center mb-12 mt-8">
      Values &amp; Process
</div>

---- UPDATE ---

-webkit-text-stroke | MDN Web Docs

5 Comments

-webkit-text-stroke is supported by a lot of browsers, see the edit.
@agoumi can you please check the update
Thanks for the solution! I believe the last 1px 1px 0 #000; is redundant?
Hi @Xitang , apologies for the mistake. Please ignore the last line. I will update the answer accordingly. Thanks for pointing it out!
@herrstrietzel Thanks for the note, I updated the answer thanks again!
5

One approach you can take is to cover over the internal lines with a second copy of the text. This produces pretty good results:

Using pseudo-elements, you could do this even without adding a second element to your HTML:

.broken {
  -webkit-text-stroke: 2px black;
}

.fixed {
  position: relative;
  /* We need double the stroke width because half of it gets covered up */
  -webkit-text-stroke: 4px black;
}
/* Place a second copy of the same text over top of the first */
.fixed::after {
  content: attr(data-text);
  position: absolute;
  left: 0;
  -webkit-text-stroke: 0;
  pointer-events: none;
}


div { font-family: 'Inter var'; color: white; }
/* (optional) adjustments to make the two approaches produce more similar shapes */
.broken { font-weight: 800; font-size: 40px; }
.fixed { font-weight: 600; font-size: 39px; letter-spacing: 1.2px; }
<link href="https://rsms.me/inter/inter.css" rel="stylesheet">

Before:
<div class="broken">
  Values &amp; Process
</div>

After:
<div class="fixed" data-text="Values &amp; Process">
  Values &amp; Process
</div>

Note, however, that using a second element is likely better for accessibility than using a pseudo-element, since you can mark it with aria-hidden and ensure screen readers won’t announce the text twice.

A complete example:

.broken {
  -webkit-text-stroke: 2px black;
}

.fixed {
  position: relative;
  /* We need double the stroke width because half of it gets covered up */
  -webkit-text-stroke: 4px black;
}
/* Place the second copy of the text over top of the first */
.fixed span {
  position: absolute;
  left: 0;
  -webkit-text-stroke: 0;
  pointer-events: none;
}


div { font-family: 'Inter var'; color: white; }
/* (optional) adjustments to make the two approaches produce more similar shapes */
.broken { font-weight: 800; font-size: 40px; }
.fixed { font-weight: 600; font-size: 39px; letter-spacing: 1.2px; }
<link href="https://rsms.me/inter/inter.css" rel="stylesheet">

Before:
<div class="broken">
  Values &amp; Process
</div>

After:
<div class="fixed">
  Values &amp; Process
  <span aria-hidden="true">Values &amp; Process</span>
</div>

4 Comments

I was a little sus of this solution, but it actually works pretty well.
one issue I found with this is if the data attribute has a quote in it (or any other special characters for that matter), it breaks this. Trying to find a way around this. Might have to just use two elements.
@CRAIG You should be able to escape quotes, see developer.mozilla.org/en-US/docs/Glossary/Entity
This is a cool hack.
5

I had a similar problem with the 'Nunito' font and this is how I solved it:

  1. Download font editor - https://fontforge.org/en-US/
  2. Open your font in the editor
  3. Select all using Ctrl + A
  4. In the top menu, select Element > Overlap > Union
  5. Then save the new font

This is an example of how the font has changed: enter image description here

This post explains why this bug happens: https://github.com/rsms/inter/issues/292#issuecomment-674993644

1 Comment

Worked perfectly for Public Sans which had the same problem. Note in my version of FontForge, it was Element -> Overlap -> Remove Overlap.
3

Its a known issue when using variable-width fonts in certain browsers. As to the why, I have no idea

https://github.com/rsms/inter/issues/292#issuecomment-674993644

Comments

1

This is how I deal with it:

  • Add 2 texts, 1 overlaps another using absolute
  • Wrap them in 1 relative container

Example code (as you are using NextJs and TailwindCSS):

import { ComponentProps } from 'react'

export const TextWithStroke = ({ text, ...props }: ComponentProps<'div'> & { text: string }) => {
      return (
        <div
          {...props}
          style={{
            position: 'relative',
            ...props.style
          }}>
          <p className="text-stroke">{text}</p>
          <p className="top-0 absolute">{text}</p>
        </div>
      )
    }

And text-stroke means -webkit-text-stroke, I have defined in a global css file, like this:

@layer utilities {
    .text-stroke {
        -webkit-text-stroke: 5px #4DDE4D;
    }
}

This is the result:
This is the result

1 Comment

Caution: this also introduces accessibility issues: a screen reader would read the (presumably) headings twice. However, you could mitigate this problem by adding an ARIA attribute such as aria-hidden="true" to one of the redundant text elements.
1

I faced similar issues while using Montserrat. I needed to show the background in place of the color of the text with white stroke. @herrstrietzel answer was helpful though, I was not sure how to make the text color transparent as that showed wierd joints of the font. First I used the paint-order property.

paint-order: stroke fill;

Then I noticed that if I set the RGBA color and adjust the opacity to the value that shows background and makes the text color look as transparent, I can achieve the desired results.

enter image description here

Here, I picked the color from the background and adjusted its opacity so that the background is apparent to some degree.

Without using the RGBA color, the wierd curves are revealed if you use the transparent color value.

enter image description here

1 Comment

Sounds interesting but frankly I can't reproduce it (when adding an alpha value to the RGBA color array I can still see the undesired overlapping strokes). Could you please share a running snippet illustrating this approach.
0

I had the same problem before, It turns out that I've initialized 'Montserrat' as my primary font and applied Some other font to an element. But when I changed the font from 'Montserrat' to 'Poppins' the problem was solved :P

2 Comments

That's not a good answer, honestly. I'd like to keep my current font.
This is not a solution but you are describing your problem. The problem is with the font, you were able to change that according to your requirements but the OP needs to use that font.
0

The problem is the default font (inter) comes with next.js . Google Inter font doesn't support this. So you just need to change the font family.

Example: here i use font-serif

<div className="outline-title pb-2 text-9xl font-bold text-center mb-12 mt-8 font-serif">
      Values &amp; Process
    </div>

1 Comment

Keep in mind the developer/web-designer may not want to change the font to an arbitrary alternative like "serif". Besides, other variable fonts may introduce the same issues.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.