1

I'm trying to create a React component that renders a stroked text with a progressive fill color based on a prop that works with any font.

Because of a bug with -webkit-text-stroke with certain type of fonts (see this), I went for a solution that overlays two spans on top of each other. One for the stroke and another for the background fill (based on this answer)

This is the component code I have so far:

type StrokeFillWipeTextProps = {
  text: string;
  color: string;
  wipeColor: string;
  progress: number;
  className: string;
};

export const StrokeFillWipeText = ({
  text,
  color,
  wipeColor,
  progress,
  className,
}: StrokeFillWipeTextProps) => {
  const wrapperStyle: React.CSSProperties = {
    position: 'relative',
    WebkitTextStroke: '10px black',
  };

  const innerStyle: React.CSSProperties = {
    position: 'absolute',
    left: 0,
    pointerEvents: 'none',
    backgroundClip: 'text',
    backgroundSize: '200% 100%',
    transition: 'background-position',
    WebkitTextStroke: 0,
    WebkitBackgroundClip: 'text',
    WebkitTextFillColor: 'transparent',
    whiteSpace: 'pre',
    color: color,
    backgroundPositionX: `${100 - progress}%`,
    backgroundImage: `linear-gradient(to right, ${wipeColor}, ${wipeColor} 50%, ${color} 50%)`,
  };

  return (
    <div className={className}>
      <span style={wrapperStyle}>
        {text}
        <span style={innerStyle} aria-hidden>
          {text}
        </span>
      </span>
    </div>
  );
};

So when progress is 0 the font color should be color. When progress is 50, the first half of the text should be colored with wipeColor and the second half with color and so on.

Here's a screenshot of this component rendered three times with progress 10, 50 and 90 respectively.

enter image description here

As you can see, with certain fonts and letters the first letter (also the last) is cropped. The actual text is overflowing the wrapper span as shown here:

enter image description here enter image description here

Here's a repro StackBlitz:

https://stackblitz.com/edit/vitejs-vite-tjgpxk?file=src%2FStrokeFillWipeText.tsx

How can I get this to work with all font families, characters and font sizes?

Also, I need the progress prop to be exact. 0 meaning no wipe fill at all, 100 meaning full wipe fill and all in betweens.

1 Answer 1

1

I'm afraid you're out of luck when using HTML elements with text-stroke applied. The main problem, we can't get the visual bounding box as defined by the glyph rendering (unless you're parsing and retrieving the actual bounding boxes via a library like opentype.js).

However, you may achieve the desired effect using SVG <text> as it has more advanced stroke options. In particular paint-order comes in handy as it allows you to add a stroke behind the fill.
Besides you can apply a gradient fill directly to the text.

progress.addEventListener('input', e => {
  let val = +e.currentTarget.value
  stopProg1.setAttribute('offset', val + '%')
  stopProg2.setAttribute('offset', val + '%')

})
body {
  padding-top: 1em;
}

svg {
  font-family: "Playwrite NO", cursive;
  margin-top: 2em;
  border: 1px solid #ccc;
  overflow: visible;
  user-select: none
}
<link href="https://fonts.googleapis.com/css2?family=Playwrite+NO:[email protected]&display=swap" rel="stylesheet">

<label>Progress <input id="progress" type="range" step="1" min="0" max="100" value="50"></label>


<svg viewBox="0 0 250 200">
  <defs>
    <linearGradient id="grad">
      <stop class="stop" offset="0%" stop-color="red" />
      <stop id="stopProg1" class="stop" offset="50%" stop-color="red" />
      <stop id="stopProg2" class="stop" offset="50%" stop-color="white" />
      <stop class="stop" offset="100%" stop-color="white" />
    </linearGradient>
  </defs>
  <text id="txt" x="50%" y="50%" fill="url(#grad)" text-anchor="middle" dominant-baseline="middle" font-size="75" stroke="#000" stroke-width="5" stroke-linejoin="round" paint-order="stroke"><tspan class="tspan">&thinsp;</tspan>Just<tspan class="tspan">&thinsp;</tspan></text>
</svg>

Unfortunately webkit/safari also struggles with the gradient fill but I found out adding a &thinsp; before and after the actual text seems to fix this issue.

Another caveat to this approach is that SVG lacks line-wrapping and width adjustment to shrink and grow the parent SVG to fit the current content.

Applying overflow:visible prevents the text from being cropped but depending on the amount of text, you need to recalculate the SVG viewBox to avoid overlapping elements.

3
  • Thanks for the answer. I want to avoid SVG for now if possible because of those issues you mention, but it seems it will come to that ultimately. If it does your SVG-based solution looks promising.
    – empz
    Commented Jul 6 at 21:05
  • I totally agree. This is a very rare case where SVG text despite its underwhelming text capabilities (OK textpaths are also a quite unique feature) can beat HTML/CSS at least in terms of text-stroke rendering due to the flawed text-stroke implementations. Quite frustrating: text related "features" tend to have a quite low priority when it comes to fixes both on the W3C specification side as well as the browser implementations. Hopefully, we'll see a fix for this in the next years ... but I wouldn't hold my breath;). Commented Jul 7 at 1:47
  • Otherwise you may also find a HTML/CSS solution by adding more constants to the equation. For instance if you may limit the component to certain fonts or if you can live without a less precise progress rendering - you may also get a decent result adding some fixed negative offsets for the absolute positioned loading bars – won't work with any font and any text. The most obvious lazy workaround would be to avoid cursive/italic fonts at all in favor of more predictable glyph renderings - e.g using capitals only. Commented Jul 7 at 1:57

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