React Rough Fiber: A React renderer for rendering hand-drawn SVGs
React Rough Fiber
A React renderer for rendering hand-drawn SVGs.
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:
<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:
<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:
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:
<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. Bothreact-three-fiber
andreact-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:
- Use a fill path to replace the stroke path
- HostContext
- SVG
<defs>
- 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:
<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: