React Rough Fiber: A React renderer for rendering hand-drawn SVGs

Open Source
Home

React Rough Fiber

A React renderer for rendering hand-drawn SVGs.

Docs and Examples

Github

Several weeks ago, I found an awesome project named perfect-freehand, which allows you to draw perfect pressure-sensitive freehand lines. The author also mentioned using this library in a Figma plugin to create freehand icons. This is really cool, and I'm inspired to create a library to render hand-drawn SVGs easily.

There are already some libraries that can render hand-drawn SVGs, such as Rough.js. However, they might be difficult to integrate with existing SVG libraries. If you are currently using SVG icon or SVG chart libraries, you cannot use Rough.js directly.

It's a nice way to create hand-drawn SVGs in React:

JSX
<RoughSVG>
  {/* ... any SVG */}
</RoughSVG>

Simple, right? This is what I want to do.

Main Idea

The main idea is to accept SVG props, like fill, stroke, d, cx, cy, etc., and then utilize Rough.js with these properties to generate SVGs.

For example, we have a SVG like this:

JSX
<RoughSVG>
  <svg width="128" height="64" xmlns="http://www.w3.org/2000/svg">
    <circle cx="32" cy="32" r="24" fill="red" />
  </svg>
</RoughSVG>

We receive the props of the circle element { cx: 32, cy: 32, r: 24, fill: 'red' }

Then we can use Rough.js to generate a hand-drawn circle(pseudo code): rough.circle(32, 32, 24, { fill: 'red' })

Now, the question arises as to how to accept SVG properties and use them efficiently to render DOM elements in React?

Some Attempts

Traverse Children

react-element-replace library provides React utility methods that transforms element subtrees, replacing elements following the provided rules.

Here is a simple example of how to apply the color: #85A600 style attribute to all span elements:

import { Replacer } from "react-element-replace"
export default function App() {
  return (
    <Replacer
      matchElement="span"
      replace={(item) => <span {...item.props} style={{ color: '#85A600' }} />}
    >
      <div>
        <span>span</span>
        <p>p</p>
      </div>
    </Replacer>
  )
}

But this methods does not work with such as React.memo:

import { memo } from "react"
import { Replacer } from "react-element-replace"

const Memo = memo(() => (
	<div>
		<span>span</span>
		<p>p</p>
	</div>
))

export default function App() {
  return (
    <Replacer
      matchElement="span"
      replace={(item) => <span {...item.props} style={{ color: '#85A600' }} />}
    >
      <Memo />
    </Replacer>
  )
}

And as the author says in the README: "It does violate the explicit design of the framework. An important caveat is that replacing elements does sometimes interfere with React renderer operations, causing errors when there are changes of state below the replacer node".

So I think this library is not suitable for my purpose.

Fake DOM

React use container.ownerDocument.createElement to create DOM elements. So I tried to substitute container.ownerDocument.createElement with my own function for creating fake DOM elements.

I use proxy to create fake DOM elements, render specified element and properties by rewiring the createElement, appenChild, setAttribute methods

Here is a straightforward example that sets the fill attribute to #85A600 whenever the received fill attribute is red:

import { useState, useRef, useEffect } from "react"
import { createPortal } from "react-dom"

const createProxy = (target, real) => {
  return new Proxy(target, {
    get(target, prop) {
      const value = prop in target ? target[prop] : real[prop]

      if (typeof value === "function") {
        return prop in target ? value.bind(target) : value.bind(real)
      }
      return value
    }
  })
}

function createFakeDocument(realDocument) {
  return createProxy(
    {
      createElementNS: (ns, type) => {
        const realElement = realDocument.createElementNS(ns, type)
        return createFakeElement(realElement)
      }
    },
    realDocument
  )
}

const fakeDocument = createFakeDocument(document)

function createFakeElement(realElement) {
  return createProxy(
    {
      _element: realElement,
      ownerDocument: fakeDocument,
      setAttribute(name, value) {
        if (name === "fill" && value === "red") {
          realElement.setAttribute(name, "#85A600")
          return
        }
        realElement.setAttribute(name, value)
      },
      appendChild(child) {
        realElement.appendChild(child._element)
      },
      removeChild(child) {
        realElement.removeChild(child._element)
      }
    },
    realElement
  )
}

const RoughSVG = ({ children }) => {
  const ref = useRef()
  const [fakeElement, setFakeElement] = useState(null)
  useEffect(() => {
    setFakeElement(createFakeElement(ref.current))
  }, [])
  return (
    <div ref={ref}>{fakeElement && createPortal(children, fakeElement)}</div>
  )
}

export default function App() {
  return (
    <RoughSVG>
      <svg width="128" height="64" xmlns="http://www.w3.org/2000/svg">
        <circle cx="32" cy="32" r="24" fill="red" />
        <circle cx="96" cy="32" r="24" />
      </svg>
    </RoughSVG>
  )
}

On this basis, we can use proxy to rewirte any function of a DOM element or document.

This method works well, but it has a problem: it's difficult to merge multiple updates.

There will be four calls to the setAttribute function if a rect element receives changes in x, y, width, and height during a render. We have to call roughjs four times, because we don't know which update is the last one.

React Renderer

Using react-reconciler to create a custom renderer for React:

JS
import Reconciler from 'react-reconciler';
const hostConfig = {
	// ...
	createInstance(type, props) {
		// ...
	},
	commitUpdate(instance, updatePayload, type, prevProps, nextProps) {
		// ...
	},
};
const CustomRenderer = Reconciler(hostConfig);

The createInstance method is used to create a DOM element, and the commitUpdate method is used to update the DOM element.

We can decide how to diff between prevProps and nextProps, and merge multiple props updates into one.

In fact, this was the method I first tried. But I encountered several challenges while implementing it:

  • Can't share contexts between React renderers, see this issue
  • It's so complex to implement a custom renderer. At the beginning, I attempted to copy the logic of react-dom. However, react-dom is a heavy libaray, and my aim is to develop a lightweight renderer.
  • Rough.js render a SVG shape into two paths, one for fill and one for stroke. So we need to set the value of the fill attribute as the value of the stroke attribute for the fill path. But it's difficult to implement this when the fill attribute is inherited from the parent element:
XML
<g fill="red" stroke="green">
  <!-- fill path. the stroke attribute should be red -->
  <path />
  <!-- stroke path. the stroke attribute should be green -->
  <path />
</g>

So, I set aside that implementation method for a while. But then, when I reconsidered, I realized that these problems were not unsolvable after all

  • its-fine provides a ContextBridge that forward contexts between renderers. Both react-three-fiber and react-konva use it.
  • preact is a lightweight React implementation. It has a diffProps function for updating properties and events, which is implemented in 157 lines of code. preact has been proven by many applications. I created my custom renderer using this function as a basis.
  • I tried three ways to solve the problem of the fill attribute being inherited from the parent element:
    1. Use a fill path to replace the stroke path
    2. HostContext
    3. SVG <defs>
    4. CSS variables

Use a fill path to mock the stroke path

The method asked us to calculate the fill path d from the stroke path d and stroke-width. Look at the following SVG code:

HTML
<path d="M 4 12 L 32 12" stroke-width="2"></path>
<path d="M 4 24 L 32 24 L 32 26 L 4 26 Z" stroke="none"></path>

The first path is the outline stroke path, and the second path is the fill path used to create a mock stroke path. They are rendered in the same result.

This method is my first attempt, and it works well at first. But then I found that it has a problem: the fill path is not smooth, when the stroke-width is thin.

I haven't solved this problem. I guess it's because of rasterization.

HostContext

react-reconciler provides a getChildHostContext(parentHostContext, type, rootContainer) function to create a host context for a child element. But there is no way to receive props from parent element in this function.

Algough someone has created an issue for this problem, it has not been resolved yet.

SVG <defs>

We can use SVG <defs> to define a pattern for an element that has fill attribute, and then use fill="url(#id)" in the child element to reference it.

export default function App() {
  return (
    <svg width="64" height="64" xmlns="http://www.w3.org/2000/svg">
      <g stroke="black" fill="#85A600">
        <defs>
          <pattern
            id="fill"
            patternUnits="userSpaceOnUse"
            width="10"
            height="10"
          >
            <rect width={10} height={10} stroke="none" />
          </pattern>
        </defs>
        <path
          d="M 0 24 L 64 24"
          fill="none"
          stroke="url(#fill)"
          strokeWidth={4}
        />
        <path d="M 0 48 L 64 48" fill="none" strokeWidth={4} />
      </g>
    </svg>
  );
}

Although this method works well, it has a potential issue of generating a lot of <defs> elements.

CSS variables

We can declare a CSS variable for an element that has fill attribute, and then use this variable in the child element's stroke attribute to reference it.

export default function App() {
  return (
    <svg width="64" height="64" xmlns="http://www.w3.org/2000/svg">
      <g
        stroke="black"
        fill="#85A600"
        style={{
          "--fill-color": "#85A600"
        }}
      >
        <path
          d="M 0 24 L 64 24"
          fill="none"
          stroke="var(--fill-color)"
          strokeWidth={4}
        />
        <path d="M 0 48 L 64 48" fill="none" strokeWidth={4} />
      </g>
    </svg>
  );
}

This method also have an issue: It only work with the inline fill attribute, does not work with the CSS fill attribute. I think this is okay because SVG libraries hardly use CSS fill attribute.

The Result

Here's an example of how to use react-rough-fiber and recharts to render a hand-drawn BarChart with only three additional lines of code:

import { RoughSVG } from 'react-rough-fiber';
import { BarChart, XAxis, YAxis, Tooltip, Legend, Bar } from 'recharts';
import { data } from './data'
import './style.css'

export default function App() {
  return (
    <RoughSVG>
      <BarChart width={730} height={250} data={data} style={{fontFamily: "'Caveat'"}}>
        <XAxis dataKey="name" />
        <YAxis />
        <Tooltip />
        <Legend />
        <Bar dataKey="pv" fill="#8884d8" stroke="#333" />
        <Bar dataKey="uv" fill="#82ca9d" stroke="#333" />
      </BarChart>
    </RoughSVG>
  )
}

Credits

react-rough-fiber is powered or inspired by these open source projects: