Bitspace: Building a Cubic Bézier Curve editor from scratch
emilwidlund
I've come across Bézier Curves many times in my creative life – most often in concepts like animations & easing configurations, where Bézier Curves comes up frequently. They are quite similar to Photoshop's & Lightroom's curve adjustment editors too, although they use a variant called Catmull-Rom Splines
.
I thought it would be quite fun to build a small cubic bézier editor for Bitspace. It's a neat way of constructing custom easing functions which then can be used in combination with linear interpolation.
Introduction to Cubic Bézier Curves
There are a couple of different variants of Bézier Curves. The two most common are Cubic & Quadratic. The cubic version uses four control points P0, P1, P2, P3
to shape the curve, while the quadratic version only uses three - P0, P1, P2
.
The cubic variant will therefore give us a smoother curvature.
By changing the positional relationships between P1
and P2
you can shape the curvature between P0 & P3
. Cubic Bézier Curves have the following format: .70, 0, .30, 1
which specifies P1 & P2
coordinates. The first two numbers are X & Y
coordinates for P1
and the last two numbers are coordinates for P2
. The bounded coordinate limits are clamped 0 -> 1.
The implementation
I've divided up the implementation into three steps;
- A basic UI with segments
- Draggable handles for control points
P1 & P2
- Rendering the curve
A UI with segments
I decided to use SVG elements to construct the editor, as it has very handy features for rendering the curve later on. As we want to create some segments in out editor UI, we can use simple SVG paths for that as well.
Let's first define our React component. I'll be using Tailwind in this example to make the styling easier.
export interface CubicBezierProps {
size: number;
}
export const CubicBezier = ({ size }: CubicBezierProps) => {
const subdivision = Math.round(size / 4);
const subdivisionColor = `#f1f5f9`;
const subdivisionWidth = 2;
return (
<div className="relative h-[226px] w-full">
<svg
className="rounded-3xl"
viewBox={`0 0 ${size} ${size}`}
height={size}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
{/** Subdivision Lines */}
{Array.from({ length: 3 }).map((_, i) => (
<line
key={i}
x1={subdivision * (i + 1)}
x2={subdivision * (i + 1)}
y1={0}
y2={size}
stroke={subdivisionColor}
strokeWidth={subdivisionWidth}
/>
))}
{Array.from({ length: 3 }).map((_, i) => (
<line
key={i}
x1={0}
x2={size}
y1={subdivision * (i + 1)}
y2={subdivision * (i + 1)}
stroke={subdivisionColor}
strokeWidth={subdivisionWidth}
/>
))}
</svg>
</div>
);
};
That should yield a simple square with vertical & horizontal subdivision segments. These segments will be helpful when the user is arranging the control points.
Draggable handles for control points P1 & P2
Let's build some interactivity into this editor. As you might know from my previous posts, I'm a really big fan of the react-draggable
package when dealing with draggable elements.
The idea here is that we create two draggable elements for each of the the "inner" control points P1 & P2
. We won't bother with P0 & P3
as they will be fixed in the lower-left & upper-right corners respectively.
We can start by extending the props model of our editor.
export interface CubicBezierProps {
points: [number, number, number, number];
size: number;
onChange: (points: [number, number, number, number]) => void;
}
points
refers to the P1 & P2
control point's X & Y
coordinates. We expect these to be normalized between 0 -> 1. When we drag the handles, we'll fire the onChange
function with updated coordinates, which we expect will trickle down as points
again from the parent.
As the control points are normalized, we need to scale them up and map to the size we're defining in our props.
export const CubicBezier = ({ points: controls, onChange, size = 222 }: CubicBezierProps) => {
const [points, setPoints] = useState<[number, number, number, number]>(controls);
const [xy1, xy2] = useMemo(() => {
const [x1, y1, x2, y2] = points;
const xy1 = { x: x1 * size, y: size - y1 * size };
const xy2 = { x: x2 * size, y: size - y2 * size };
return [xy1, xy2];
}, [points]);
// Call the onChange-handler whenever we have new points
useEffect(() => {
onChange(points);
}, [points]);
// Our subdivision logic
...
}
We should now have what we need to render handles for the P1 & P2
control points. But first, let's create a simple ControlPoint-component which uses a Draggable
component from react-draggable
.
const ControlPoint = ({ editorSize, ...props }: Partial<DraggableProps> & { editorSize: number }) => {
return (
<Draggable
{...props}
// Needed to cancel the Draggable nature of Bitspace nodes
onMouseDown={e => e.stopPropagation()}
bounds={{
left: 0,
right: editorSize,
top: 0,
bottom: editorSize
}}
>
<div className="w-2 h-2 rounded-full bg-[#94a3b8] absolute -top-1 -left-1" />
</Draggable>
);
};
This <ControlPoint />
component will take DraggableProps
as base props, along with the editor's size. We also make sure to set the bounds
property to restrict the draggable control point within the editors bounds.
Now it's time to use this component in our editor. Put two <ControlPoint />
components under the SVG
element which we use for subdivision lines.
...
</svg>
<ControlPoint
position={xy1}
onDrag={(e, data) => {
setPoints(points => [data.x / size, 1 - data.y / size, points[2], points[3]]);
}}
editorSize={size}
/>
<ControlPoint
position={xy2}
onDrag={(e, data) => {
setPoints(points => [points[0], points[1], data.x / size, 1 - data.y / size]);
}}
editorSize={size}
/>
</div>
)
}
Let's go ahead and draw two paths from P0 & P3
to P1 & P2
respectively.
...
const subdivision = Math.round(size / 4);
const subdivisionColor = `#f1f5f9`;
const subdivisionWidth = 2;
// Define a color for the handles
const handleColor = `#94a3b8`;
...
{/** Put these lines inside the SVG element */}
<line x1={0} x2={xy1.x} y1={size} y2={xy1.y} stroke={handleColor} strokeWidth="2" />
<line x1={size} x2={xy2.x} y1={0} y2={xy2.y} stroke={handleColor} strokeWidth="2" />
Rendering the curve
Great work! We've arrived at the last step of this Editor implementation. And it's a very trivial step actually. We have everything we need to render our Cubic Bézier Curve.
And we're quite lucky – the HTML SVG implementation has a very handy way of rendering these curves for us using the C
command.
<path
d={`M 0 ${size} C ${xy1.x} ${xy1.y}, ${xy2.x} ${xy2.y}, ${size} 0`}
stroke="#0000ff"
strokeWidth={2}
fill="none"
/>
Yes, it's actually THAT easy. You should now have a fully functioning Cubic Bézier Curve editor which emits updated control point values whenever you drag around the handles.
The full source code can be found below if you didn't follow along.
import { useEffect, useMemo, useState } from 'react';
import Draggable, { DraggableProps } from 'react-draggable';
export interface CubicBezierProps {
points: [number, number, number, number];
size: number;
onChange: (points: [number, number, number, number]) => void;
}
export const CubicBezier = ({ points: controls, onChange, size = 222 }: CubicBezierProps) => {
const [points, setPoints] = useState<[number, number, number, number]>(controls);
const [xy1, xy2] = useMemo(() => {
const [x1, y1, x2, y2] = points;
const xy1 = { x: x1 * size, y: size - y1 * size };
const xy2 = { x: x2 * size, y: size - y2 * size };
return [xy1, xy2];
}, [points]);
useEffect(() => {
onChange(points);
}, [points]);
const subdivision = Math.round(size / 4);
const subdivisionColor = `#f1f5f9`;
const subdivisionWidth = 2;
const handleColor = `#94a3b8`;
return (
<div className="relative h-[226px] w-full">
<svg
className="rounded-3xl"
viewBox={`0 0 ${size} ${size}`}
height={size}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
{/** Subdivision Lines */}
{Array.from({ length: 3 }).map((_, i) => (
<line
key={i}
x1={subdivision * (i + 1)}
x2={subdivision * (i + 1)}
y1={0}
y2={size}
stroke={subdivisionColor}
strokeWidth={subdivisionWidth}
/>
))}
{Array.from({ length: 3 }).map((_, i) => (
<line
key={i}
x1={0}
x2={size}
y1={subdivision * (i + 1)}
y2={subdivision * (i + 1)}
stroke={subdivisionColor}
strokeWidth={subdivisionWidth}
/>
))}
{/** Handle Lines */}
<line x1={0} x2={xy1.x} y1={size} y2={xy1.y} stroke={handleColor} strokeWidth="2" />
<line x1={size} x2={xy2.x} y1={0} y2={xy2.y} stroke={handleColor} strokeWidth="2" />
{/** Curve */}
<path
d={`M 0 ${size} C ${xy1.x} ${xy1.y}, ${xy2.x} ${xy2.y}, ${size} 0`}
stroke="#0000ff"
strokeWidth={2}
fill="none"
/>
</svg>
<ControlPoint
position={xy1}
onDrag={(e, data) => {
setPoints(points => [data.x / size, 1 - data.y / size, points[2], points[3]]);
}}
editorSize={size}
/>
<ControlPoint
position={xy2}
onDrag={(e, data) => {
setPoints(points => [points[0], points[1], data.x / size, 1 - data.y / size]);
}}
editorSize={size}
/>
</div>
);
};
const ControlPoint = ({ editorSize, ...props }: Partial<DraggableProps> & { editorSize: number }) => {
return (
<Draggable
{...props}
onMouseDown={e => e.stopPropagation()}
bounds={{
left: 0,
right: editorSize,
top: 0,
bottom: editorSize
}}
>
<div className="w-2 h-2 rounded-full bg-[#94a3b8] absolute -top-1 -left-1" />
</Draggable>
);
};
Thank you so much for reading this small piece on Bitspace's Cubic Bézier Curve editor implementation. I can't wait to let you all play around with it when the Early Alpha opens up.
Until next time!