Introduction
Last Updated
May 23rd, 2018
May 23rd, 2018
My goal with this blog is to create helpful content for front-end web devs, and my newsletter is no different! I'll let you know when I publish new content, and I'll even share exclusive newsletter-only content now and then.
No spam, unsubscribe at any time.
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 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:
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 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 section.
The simplest form of SVG drawings use shape elements, like <rect>
or <ellipse>
.
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 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. 🤞
To summarize how this works:
startPoint
, controlPoint
, and endPoint
.path
using these state variables.draggingPointId
.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 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 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 y over the change in x:
slope
= (y2 - y1) / (x2 - x1)
= (Δy) / (Δx)
There's also this rascal, the linear equation formula. This allows us to graph a straight line, and figure out the y value for a given x value. By convention, slope is given the variable a:
y = ax + b
How does this relate to interpolation? Well, let's imagine that our Bézier curve's control point, when it's all curvy, is 200 pixels away from its flattened position, so we'll give it an initial y value of 200. The x in this case is really a measure of progress, so we'll have it range from 0 (completely curvy) to 1 (completely flat). If we graph this line, we get this:
To clarify, this line represents the range of possible y values for a quadratic Bézier curve's control point. Our x values represent the degree of "flattening"; this is useful to us because we want to be able to provide an x value like 0.46, and figure out what the corresponding y value is (our x 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 }
.
(Δy) / (Δx)
= (0 - 200) / (1 - 0)
= -200 / 1
= -200
.Filling it in:
y = -200x + 200
If it's 25% of the way through, x will be 0.25, and so our y value would be y = (-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:
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:
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.getBoundingClientRect
, 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.scrollRatio
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.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:
I'm excited to see what you build with this technique! Let me know on Twitter.
Learn more about the math and mechanics behind Bézier curves with these two amazing resources: