DEV Community

Cover image for Generating Knockout Text with the CSS Paint (Houdini) API
james south
james south

Posted on • Originally published at dev.to

Generating Knockout Text with the CSS Paint (Houdini) API

In my first article on the new CSS Paint (Houdini) API, I covered three use cases for Houdini along with polyfilling it in non-supporting browsers and building with webpack. Today I want to discuss combining Houdini with a knockout text technique to easily create attractive, generative text effects. Since I have already covered the polyfill, I have chosen not to use it for this article's demos, so they only work in Chrome; other browsers will just show a black fallback. The repo for this article is here:

GitHub logo jamessouth / knockout-demo

repo for my article on css paint and knockout text

Knockout text is a visual effect where the text content of an element is cut out, revealing the background behind it, thereby giving color to the letters so that they contrast with the foreground and can be read. In web development, there are several ways to achieve knockout text; for these demos I went with using the background-clip: text CSS property as it is widely supported (prefixed), simple, and accessible. Check out my 15 Puzzle Generator to see another knockout text technique using images, pseudo content and the mix-blend-mode CSS property, and the accessibility hack (a tiny, invisible <h1> tag) that was subsequently required. The demos for this article are live here.

The Markup

  <!-- index.html -->
  <body>
    <h2>Claude Monet</h2>
    <h2>102 Colored Stripes</h2>
    <h2>85 Colored Spheres</h2>
  </body>
  <style>
    h2{
      background-image: linear-gradient(black, black);
    }
    h2:first-of-type{
      background-image: paint(demo1);
    }
    h2:nth-of-type(2){
      background-image: paint(demo2);
    }
    h2:last-of-type{
      background-image: paint(demo3);
    }
  </style>
index.html

Here we just have three <h2> tags with our text, as you might see in a real document. Throw on an <h1> for a page title and this is accessible to screen readers as a set of level 2 headings. The <style> tag for calling our paint worklets is needed to work around an apparent caching issue as discussed in my previous article.

The Styling

//demo.scss
*{
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body{
  display: flex;
  background: #fbfbfb;
  justify-content: space-around;
  flex-direction: column;
  align-items: center;
}
h2{
  color: transparent;// can be anything but background only visible to extent transparent
  font-size: 68px;
  background-clip: text;
  -webkit-background-clip: text;// must be prefixed
  text-align: center;
  line-height: 76px;
  font-weight: 500;
  margin: .3em .25em;
}
h2:first-of-type{
  --brushstrokes: 3825;
  font-family: 'Dr Sugiyama', cursive;
  width: 60%;// reduces paint area when text takes 2 lines to maintain desired visual effect
  //of just a few dark gaps in the 'hay'
}
h2:nth-of-type(2){
  --stripes: 102;
  font-family: 'Amarante', cursive;
  line-height: 78px;// without this the descender of the 'p' gets cut off
}
h2:last-of-type{
  --spheres: 85;
  font-family: 'Limelight', cursive;
}
@media screen and (min-width: 450px){
  h2{
    font-size: 88px;
    line-height: 96px;
    max-width: 501px;// otherwise paint area would extend across viewport, requiring more
//objects to be drawn in our worklet to get the desired effect
    margin: .4em 0;
  }
  h2:first-of-type{
    width: 450px;// otherwise the cross-stroke of the 't' gets cut off
  }
  h2:nth-of-type(2){
    line-height: 102px;// also used to preserve the descender of the 'p'
  }
}
@media screen and (min-width: 775px){
  h2:nth-of-type(2){
    max-width: initial;// allows to expand as one line
  }
}
@media screen and (min-width: 942px){
  h2{
    margin: .5em 0;
  }
  h2:last-of-type{
    max-width: initial;// allows to expand as one line
  }
}

demo.scss

Pretty simple styles, just some basic flexboxing on the body then some typical text styling for the <h2> tags, each of which has a CSS custom property that we will use in their respective worklets. The knockout text effect is created by the transparent text color (the background will only be visible to the extent the text color is transparent) coupled with the background-clip: text property (limits the appearance of the background image to the area of the text), which must be prefixed in most browsers.

We want the background to be just big enough to completely cover the text. Any uncovered text will just be the text color. However, if our background size far exceeds the area of the text, our worklet will be doing a lot of unutilized work, which is sub-optimal and could be a problem if you animate the background. Making a few simple tweaks to properties such as line-height, width, and font-size (and also padding and border) minimizes the background size and our paint function's complexity while still containing the text. Now, let's check out the worklets!

Demo 1

Demo 1

Here I'm trying to recreate Claude Monet's famous haystacks as seen, for example, in this painting. By limiting the width of the background, I can keep the number of brushstrokes down to a reasonable 3,825 πŸ˜ƒ. If the background were wider, the brushstrokes would be diluted and more black areas would be visible, so more strokes would be required for the same look, increasing the complexity of the paint function. I chose the Dr Sugiyama Google font to vaguely mimic Monet's signature. To see the underlying background drawing, open up dev tools and uncheck -webkit-background-clip: text.

//(partial) demo1.js - static methods omitted, see link to file below 
  paint(ctx, { width, height }, props) {
    const brushstrokes = props.get('--brushstrokes');

    ctx.fillStyle = 'rgb(30, 10, 0)';
    ctx.fillRect(0, 0, width, height);

    for (let i = 0; i < brushstrokes; i += 1) {
      const [x, y] = Demo1.getRandomPoint(width, height);
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x + Demo1.getXAdjustment(8), y + Demo1.getYAdjustment(28));
      ctx.lineWidth = Demo1.getWidth();
      ctx.strokeStyle = `rgba(
        ${Demo1.getNumber(201, 40)},
        ${Demo1.getNumber(148, 45)},
        ${Demo1.getNumber(102, 45)},
        ${Demo1.getNumber(70, 31) / 100}
      )`;
      ctx.stroke();
    }
  }
demo1.js

Pretty simple, just looping through the number of brushstrokes from CSS and drawing a short line of 'hay' in a random straw color.

Demo 2

Demo 2

This one is also just a bunch of colored lines, very simple to do yet attractive visually.

//(partial) demo2.js - static methods omitted, see link to file below 
  paint(ctx, { width, height }, props) {
    const stripes = props.get('--stripes');

    ctx.fillStyle = 'rgba(30, 30, 30, .6)';
    ctx.fillRect(0, 0, width, height);

    for (let i = 0; i < stripes; i += 1) {
      const start = Demo2.getRandomPoint(width, height);
      const end = Demo2.getRandomPoint(width, height);
      ctx.beginPath();
      ctx.moveTo(...start);
      ctx.lineTo(...end);
      ctx.lineWidth = Demo2.getWidth();
      ctx.lineCap = 'square';
      ctx.strokeStyle = `rgba(
        ${Demo2.getColor(16, 150)},
        ${Demo2.getColor(18, 150)},
        ${Demo2.getColor(12, 200)},
        ${Demo2.getTransparency()}
      )`;
      ctx.stroke();
    }
  }
demo2.js

The font here is the Amarante Google font, about the most Art Nouveau-style font they have.

Tip: On Google fonts you can only search font names, but if you want to search for a certain style of font (or anything that appears in the font descriptions but not the names), search the GitHub repo!

Demo 3

Demo 3

For Demo 3, I experimented with drawing spheres (adapted from the MDN) and I think they turned out great. Just a little more complex than stripes but nothing too heavy. The font is Limelight, one of many Art Deco-style Google fonts.

//(partial) demo3.js - static methods omitted, see link to file below 
  paint(ctx, { width, height }, props) {
    const spheres = props.get('--spheres');

    ctx.fillStyle = 'rgb(10, 10, 10)';
    ctx.fillRect(0, 0, width, height);

    for (let i = 0; i < spheres; i += 1) {
      const radius = Demo3.getColor(4, 60);
      const [x, y] = Demo3.getRandomPoint(width + 1, height + 1);
      const [r, g, b] = Demo3.makeColor();
      const radgrad = ctx.createRadialGradient(
                        x,
                        y,
                        0,
                        x + (radius / 4),
                        y + (radius / 4),
                        radius
      );
      radgrad.addColorStop(0, '#ffffff');
      radgrad.addColorStop(0.99, `rgba(${r}, ${g}, ${b}, 1)`);
      radgrad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
      ctx.fillStyle = radgrad;
      ctx.fillRect(0, 0, width, height);
    }
  }
demo3.js

Radial gradients in canvas drawing take two circles as arguments and then color stops can be added. You can then apply the gradient as either a fill style or a stroke style.

Conclusion

Knockout text is a cool effect that is easy to implement accessibly, and when we use Houdini to make the backgrounds, we can randomly generate attractive patterns to show through our knocked-out text as an alternative to having to load images. This technique works with the Houdini polyfill and can be used anywhere; the only limit is your imagination! I hope that you found this article useful and that you will please like and share with the world!

Top comments (0)