Search code examples
csssassnext.jsfrontendtailwind-css

Text Stroke (-webkit-text-stroke) css Problem


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


Solution

  • -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