Pattern making with CSS Paint

Vera Molnár inspired CSS only pattern

cell.js Naive attempt at replicating Vera Molnár's Desordres (1974) testing out CSS Houdini (detail).

Exposing most of the Canvas API save for text and image rendering, Paint Worklets allow for putting together a wide range of interesting graphics. Coupled potentially with Web Components for isolating and CSS custom properties for editing or animating parameters, they make it easy to create portable pattern generators.

Pick a unit trace of some sort to get started. In the simplest case that might be a line or a curve. In the example above the unit is a rectangle. It's dimensions are linked to the HTML element the paint() is applied to later on. If the element is a square, the trace will be square. CSS variables are used for choosing fill, stroke style and line width,

// Sample worklet.js
class QuadPainterMaybe {
  static get inputProperties() {
    return [
      '--x-fill-style',
      '--x-line-width',
      '--x-stroke-style'
    ]
  }

  // Conventiently automatically called when geometry changes
  paint(context, geometry, properties) {
    // Use the longest side for the diameter
    const { width: w, height: h } = geometry
    const radius = Math.max(w, h) / 2

    // Ninety degrees
    const Q = Math.PI * 0.5

    // For converting degrees to radians
    const D = Math.PI / 180

    // Set options
    for (const [k,v] of properties) {
      // Convert from e.g. '--x-fill-style' to 'fill-style' for configuring context
      const s = k.replace('--x-', '')

      context[s] = v.toString()
    }

    // Move origin to geometry center, rotate and start drawing
    context.translate(w / 2, h / 2)
    context.beginPath()

    // Lay out vertices, anticlockwise from the top right quadrant
    Array.from({ length: 2 * 2 })
      .map((_, i) => {
        // Get polar position for each vertex
        const t = ((i % 2 ? Q : 0) + (i * Q)) * D

        // Pol2car
        const x = radius * Math.cos(t)
        const y = radius * Math.sin(t)

        context.lineTo(x, y)
      })

    context.closePath()
    context.stroke()
    context.fill()
  }
}

registerPaint('quad', QuadPainterMaybe)

Once installed, the worklet is available for calling via CSS on properties that accept an image: background, border etc. To achieve tiling it might be useful to set up a custom element to automatically create a range of empty <div> tags to then apply some paint() to,

/* style.css */
div[is="grid-maybe"] {
  /* Do a 4x4 grid out of the 16 cells in the shadow DOM below */
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}
// script.js
class GridMaybe extends HTMLDivElement {
  constructor() {
    super()

    this.attachShadow({ mode: 'open' })
  }

  connectedCallback() {
    if (this.isConnected) {
      // Create a `data-size` total of empty `<div>` cells
      Array.from({ length: this.dataset.size })
        .map(() => document.createElement('div'))
        .forEach((x) => {
          this.shadowRoot.appendChild(x)
        })
    }
  }
}

customElements.define('grid-maybe', GridMaybe, { extends: 'div' })

Alright, the worklet takes care of drawing the master cell, the custom element lays out the grid and to recurse, translate, rotate, color, or scale simply use CSS,

/* style.css */
@supports (background: paint(_)) {
  div {
    --x-fill-style: transparent;
    --x-line-width: 1px;
    --x-stroke-style: black;

    /* Draw five rectangles of succesively smaller height on each shadow DOM <div> cell */
    background-image: paint(quad), paint(quad), paint(quad), paint(quad), paint(quad);
    background-position: center;
    background-repeat: no-repeat;
    background-size: 100%, 80%, 60%, 40%, 20%;
  }

  div:nth-of-type(2n + 1) {
    /* Give odd cells gaps */
    background-size: 100%, 0%, 50%, 0%, 25%;
  }

  div:nth-of-type(7) {
    /* Do something exceptionally fancy */
    transform: translate(-1px, -1px) rotate(1deg);
  }

And to install in a way that keeps the styling scoped to decendants of the host element in order to support multiple grid instances on a given page,

<!-- client.html -->
<div is="simple-grid" size="16">
  <!-- insert shadow DOM scoped styles -->
</div>
// script.js
customElements.whenDefined('simple-grid')
  .then(() => {
    if ('paintWorklet' in CSS) {
      CSS.paintWorklet.addModule('worklet.js')
        .then(() => {
          const owner = document.querySelector('div[is="simple-grid"]')
          const style = document.createElement('style')

          style.textContent = `@import 'style.css';`

          // Keep styles scoped
          owner.shadowRoot.appendChild(style)
        })
        .catch(console.log)
    }
  })

Theoretically, the above should work on Safari when the CSS Paint option is enabled in the Develop menu. It sort of does when the script is read in as a string. The supposed property Map-type argument in the paint() method is also buggy on Safari. Only Chrome and Opera seem fairly caught up with most of the spec. Module home is @thewhodidthis/cell ›

Reference