Dynamic Bézier Curves

First off - woohoo! This is my first published post on the new blog. I'm super excited. Thanks for checking it out! 🥂

While building this blog, I wanted it to feel whimsical, with plenty of charming interactions and animations. I built this while working on my React Europe talk, The Case for WhimsyThe Case for Whimsy, and so it was very much on my mind.

For example, did you notice that as you started scrolling on this page, the Bézier curves that border the green title hero thingy started flattening? Keep your eye on the swoopy curves just above the post text as you scroll through the top of the document. Notice how they become flat as they approach the header at the top of the viewport?

In a delightful bit of serendipity, I realized while building the blog that this feature would make a great first blog post!

The whole reason I started this blog was that I wanted a way to build dynamic, interactive articles that are more effective at sharing and teaching concepts. Unlike with plain text on Medium, this blog is a fully-powered React app, and so I can create and embed interactive elements that help the reader build an intuitive understanding of the subject being presented. These dynamic "flattenable" Bézier curves are a perfect subject for this format, as they have underlying complexity that would be difficult to explain with words alone.

In this maiden blog post, we'll go through the basics of working with Bézier curves and SVG in React.js. We'll learn how to build dynamic curves that respond to user input:

This is an interactive demo! Feel free to poke around with it.

For achieving this effect, we'll use SVG. We could also use HTML Canvas, but I generally prefer to work with SVG. It's more React-like in its API, there's less complexity in setting it up, and it's more a11y-friendly.

While doing a deep dive into SVG is beyond the scope of this post (I'd recommend the W3Schools tutorialW3Schools tutorial for that), we'll cover the basics, and show how to create some shapes from scratch. Experienced SVG-ers can jump to the next sectionthe next section.

The simplest form of SVG drawings use shape elements, like <rect> or <ellipse>.

Note: All the code snippets in this page are React elements, not HTML.

These shapes are straightforward and declarative, but that simplicity comes at the cost of flexibility; you can only create a handful of different shapes.

To do neat curvy things, we need to use the <path> element. This swiss-army-knife of an SVG primitive lets you specify a sequence of steps to execute, in a seemingly-inscrutable bundle of letters and numbers:

The interactive code snippet above uses 2 commands:

  • M, which instructs the path to move to a specific coordinate.

  • L, which instructs the path to create a line from the current position to the specified coordinate.

After the commands M and L, we see some numbers. These can be thought of as "arguments" for the commands. In this case, the arguments are coordinates; both commands require a single X/Y pair.

In other words, we can read the above path as: "Move to {x: 100, y: 100}, then draw a line to {x: 200, y: 100}", and so on.

The coordinate system is relative to the values specified in the viewBox. The current viewbox specifies that the viewable area has a top-left corner of 0/0, a width of 300, and a height of 300. So all of the coordinates specified in the path are within that 300x300 box.

The viewBox is what makes SVGs scalable; we can make our SVG any size we like, and everything will scale naturally, since the elements within our SVG are relative to this 300x300 box.

The path element features quite a numberquite a number of these commands. There are two that are relevant for our purposes:

  • Q, which instructs the path to create a quadratic Bézier curve.

  • C, which instructs the path to create a cubic Bézier curve.

Bézier curves are surprisingly common. Due to their versatility, they're a staple in most graphics software like Photoshop, but they're also used as timing functions: if you've ever used non-linear CSS transitions (like the default "ease"), you've already worked with Bézier curves!

But what are they, and how do they work?

A Bézier curve is essentially a line from a start point to an end point that is acted upon by one or more control points. A control point curves the line towards it, as if the control point was pulling it in its direction.

The following line looks like a straight line, but check out what happens when you move the points around—try dragging the middle control point up and down.

The line above is a quadratic Bézier curve; this means that it has a single control point. I'm guessing it gets its name from the fact that you can create parabola-like shapes with it:

A cubic Bézier curve, in contrast, has two control points. This allows for much more interesting curves:

The syntax for Bézier curves in SVG path definitions is a little counter-intuitive, but it looks like this:

The thing that makes this counter-intuitive, to me at least, is that the startPoint is inferred in the Q command; while there are 3 points needed for a quadratic Bézier curve, only 2 points are passed as arguments to Q.

Similarly, for a cubic Bézier curve, only the control points and the end point are provided to the C command.

This syntax does mean that curves can conveniently be chained together, as one curve starts where the last one ends:

Ok, I think that's enough playing with vanilla SVGs. Let's see how we can leverage React to make these curves dynamic!

Up to this point, we've been looking at static SVGs. How do we make them change, over time or based on user input?

Well, in keeping with the "meta" theme of this blog post, why not examine the draggable-with-lines Bézier curves from earlier in this post?

There's a fair bit of code to manage this, even in this slightly-simplified snippet. I've annotated it heavily, which hopefully makes things easier to parse. 🤞

Note: The full version, with support for touch events, can be found on GitHubon GitHub.

To summarize how this works:

  • React holds variables in component state for startPoint, controlPoint, and endPoint.

  • In the render method, we build the instructions for the path using these state variables.

  • When the user clicks or taps on one of the points, we update the state to keep track of which point is moving with draggingPointId.

  • As the user moves the mouse (or finger) across the SVG's surface, we do some calculations to figure out where the currently-dragging point needs to move to. This is made complex by the fact that SVGs have their own internal coordinate system (viewBox), and so we have to translate the on-screen pixels to this system.

  • Once we have the new X/Y coordinate for the active point, setState lets React know about this state change, and the component re-renders, which causes the path to be re-calculated.

By using React's update cycle to manage the point coordinates, there is added overhead of letting React run its reconciliation cycle on every mousemove. Is this prohibitively expensive?

The answer is that it depends. React's reconciliation can be surprisingly fast, especially when dealing with such a small tree (after all, the only thing that needs to be diffed is an SVG). Especially in "production" mode, when React doesn't have to do a lot of dev warning checks, this process can take fractions of a millisecond.

I wrote an alternative implementationalternative implementation that updates the DOM directly. It does run faster (about 50% faster in my quick test), but both implementations still clock in under 1ms on modern high-end hardware. On the cheapest Chromebook I could find, the "unoptimized" one still averaged 50fps or so.

I seem to have gotten a little side-tracked! Our original goal was to create a Bézier curve that flattens itself on scroll.

Given what we've gone over so far, we have almost all of the tools we need to solve this problem! A Bézier curve with its control point(s) directly between the start and end points is actually a straight line! So we need to transition the control points from their curvy values to a flat value.

We need a way to interpolate values. We know where the control points should be at 0% and 100%, but what about when the user is 25% scrolled through the content?

While we could be fancy and ease the transition, a linear transformation works just fine for our purposes. So when the user is 50% scrolled through the content, the control points will be 50% of the way between their initial curvy value, and the flat-line value.

For this, some secondary-school maths will come in handy. If you're already up to speed on interpolation, you can skip this bitskip this bit.

If you plumb the depths of your memory, you may remember how to calculate the slope of a line. The slope tells you how the line changes over time. We calculate it by dividing the change in yyy over the change in xxx:

slope=y2y1x2x1=ΔyΔxslope = \frac{y2 - y1}{x2 - x1} = \frac{Δy}{Δx}slope=x2x1y2y1=ΔxΔy

There's also this rascal, the linear equation formula. This allows us to graph a straight line, and figure out the yyy value for a given xxx value. By convention, slope is given the variable aaa:

How does this relate to interpolation? Well, let's imagine that our Bézier curve's control point, when it's all curvy, is 200200200 pixels away from its flattened position, so we'll give it an initial yyy value of 200200200. The xxx in this case is really a measure of progress, so we'll have it range from 000 (completely curvy) to 111 (completely flat). If we graph this line, we get this:


To clarify, this line represents the range of possible yyy values for a quadratic Bézier curve's control point. Our xxx values represent the degree of "flattening"; this is useful to us because we want to be able to provide an xxx value like 0.460.460.46, and figure out what the corresponding yyy value is (our xxx value will come from user input, like the percentage scrolled through the viewport).

To make our formula work, we need to know at least 2 points on this line. Thankfully, we do! We know that the initial position, fully curved, is at { x: 0, y: 200 }, and we know that the curve becomes fully flattened at { x: 1, y: 0 }.

  • The slope would be equal to ΔyΔx=020010=2001=200\frac{Δy}{Δx} = \frac{0 - 200}{1 - 0} = \frac{-200}{1} = -200ΔxΔy=100200=1200=200.

  • Our bbb value is the y-axis intercept, which is our initial curved value, 200200200.

  • xxx will be the ratio of scroll-through, between 0 and 1, that we'll get from our scroll handler.

Filling it in:

y=200x+200y = -200x + 200y=200x+200

If it's 25% of the way through, xxx will be 0.25, and so our yyy value would be y=(200)(0.25)+200=150y = (-200)(0.25) + 200 = 150y=(200)(0.25)+200=150, which is correct: 150 is 1/4 of the way between 200 and 0.

Here's our function that performs the above calculations:

Looks like teenage-me was wrong; algebra is useful and practical!

We're in the home stretch now! Time to combine all these ideas into something usable.

Let's start by building a component that contains our scroll-handler to interpolate from the bottom of the viewport to the top, and connect those values to a Bézier curve in the render function:

This initial approach seems to work OK! There are two things I want to improve though:

  • The "timing" of the flattening feels wrong to me.
    When the curve fully enters the viewport, it's already starting to be flattened. We don't get to see it in 100%-curved form. Worse, it hasn't finished flattening by the time it scrolls out of view! This is because this page has a header that takes up the top 50px of the viewport, and we aren't taking that into account.
    To solve these problems, we need to define a scrollable area, instead of using the viewport.
  • This component is doing an awful lot. It feels like we could extract a couple components from this. Refactoring it would not only make it easier to follow/understand, but it would make it more reusable.

Let's fix these problems. Here's a refactored version:

Ahh, much nicer! The effect is more pleasant as the flattening animation happens within a smaller scroll window, and the code is easier to parse. As a bonus, our BezierCurve and ScrollArea components are generic, so they could be useful in totally different contexts.

The two versions above were written without any concern for performance. As it turns out, the performance is not so bad; on my low-end Chromebook, it stutters a little bit from time to time but mostly runs at 60fps. On my sluggish iPhone 6, it runs well enough (the biggest issue on mobile is that the browser address bar changes on scroll. Because of that, it may be wise to disable scroll-based things like this altogether on mobile).

That said, your mileage may vary. If you want to improve performance, there are a few ways this could be optimized:

  • ThrottleThrottle the scroll-handler in ScrollArea that it only fires every 20ms or so. This is to calm down certain touch-screen or trackpad interfaces that can fire far more often than is required.
  • One of the more expensive parts of this effect is that we're interacting with the DOM, via getBoundingClientRectgetBoundingClientRect, on every scroll event. Ideally, we could cache the position of our ScrollArea on mount, and then check the current scroll distance against this value.
    Unfortunately, this method opens up new problems. It assumes that nothing between the top of the document and your Bézier curve will change height, since our calculations assume a static distance between the two. Mobile browsers like iOS Safari will hide their chrome as you scroll down, so we'd have to factor that in as well.
    It's far from impossible, but it wasn't worth the trouble for me, given that performance was satisfactory on the devices I'm targeting.
  • By storingscrollRatio in state and re-rendering whenever it changes, React needs some time to work out how the DOM has changed as a result of the scroll.
    The refactor to extract several components, while very good for DX and reusability, also means that React has a slightly more complex tree to reconcile.
    This all sounds a bit scary, but as we discovered earlier, React's reconciliation process is very quick on small trees like this. The cost of the refactor was negligible on my chromebook.
    If you really need to extract every drop of performance, you could work with the DOM directly, by setting the new path instructions using setAttribute. Note that you'd need to store everything in 1 component again.

Whew, you made it through this Bézier deep-dive!

The technique described in this blog post is foundational, and there's tons of flourishes you can add on top of it:

  • This blog uses 3 layered Bézier curves with different fill colours to provide depth to the experience.

  • You can experiment with different easings for the interpolation (Bézier curves are often used for timing functionstiming functions, after all!). What if the curve got even more dramatic before smoothing it out?

I'm excited to see what you build with this technique! Let me know on Twitteron Twitter.

This blog post is the first thing I've tried in this format. It was a heck of a lot of fun to build, but it was also a tremendous amount of work compared to writing a Medium post.

One of the ways you can help signal to me that this content is worth the extra work is by signing up for the newsletter! The newsletter will be sent once every few weeks, and subscribers will be the first to hear about new posts.

I promise it'll be worthwhile and not-too-often. Quality over quantity!

Additional Reading

Learn more about the math and mechanics behind Bézier curves with these two amazing resources: