Pure CSS Motion Along Bézier Curves
Bézier curves show up everywhere in front-end work: as vector paths in SVG and fonts, as drawing tools in design software, and as timing functions for CSS transitions and animations. But what if you want to move an element along a Bézier curve as a literal motion path, using only CSS? This article walks through three approaches that make it possible.
A 2D cubic Bézier curve is defined by four points: two endpoints (P0 and P3) and two control points (P1 and P2). Quadratic versions use just one control point. The underlying functions are polynomials, which means they can be expressed in several equivalent algebraic forms — that's the key to using them in CSS animations.
Method 1: Warping Time Per Axis
The first technique leverages the fact that the cubic-bezier() timing function itself is a Bézier curve. If you can match the curve's control points, you can move an element along a path by:
- Setting up
@keyframesto travel from one endpoint to the other. - Applying a separate
animation-timing-functionto each coordinate axis.
/* pseudo CSS code */
div {
animation-name: move-x, move-y;
/*
Define:
f(x, a, b) = (x - a) / (b - a)
qx1 = f(p1.x, p0.x, p3.x)
qx2 = f(p2.x, p0.x, p3.x)
qy1 = f(p1.y, p0.y, p3.y)
qy2 = f(p2.y, p0.y, p3.y)
*/
animation-timing-function:
cubic-bezier(1/3, qx1, 2/3, qx1),
cubic-bezier(1/3, qy1, 2/3, qy2);
}
@keyframes move-x {
from {
left: p0.x;
}
to {
left: p3.x;
}
}
@keyframes move-y {
from {
top: p0.y;
}
to {
top: p3.y;
}
}
The The result is a pure CSS animation, as demonstrated in the interactive example: you can drag the endpoints and control points, and the CSS variables update accordingly, redrawing the curve and the animation in real time. This method works well for most use cases, but it has a notable limitation: it fails when both endpoints share the same horizontal or vertical line due to division by zero (a tiny offset fixes this). It also only works for cubic curves, and the timing options are restricted. The examples use A second technique emerges from an interesting quirk of CSS animations. If an element is animated with multiple Here, The demo visualizes this construction: the blue dot shows the intermediate position that the second animation starts from, while the black dot represents the element itself. To create a quadratic Bézier curve between points The concept extends naturally to cubic curves (with points This method also handles 3D curves without extra work — the previous examples already worked in three dimensions if you supply the The recursive definition of a Bézier curve provides a third path. For a curve defined by points By creating CSS variables for each interpolated point and updating them with keyframes that use This approach mirrors the standard math construction, making it the most intuitive for those comfortable with the geometry. It also handles 3D curves and higher-order curves with straightforward extensions. However, One major difference between these methods is how they handle easing. "Linear" here means the Bézier parameter Methods 2 and 3 are simple: any Method 1 is trickier since its timing function is already a All move-x and move-y keyframes handle the start and end positions, while the cubic-bezier() parameters are computed so that left and top always match the desired curve's coordinates at any point in time. /p>
1/3 and 2/3 for the control point parameters to achieve linear timing; you can alter them, but the flexibility pales compared to the other methods.Method 2: Competing Animations
@keyframes rules, they don't necessarily override each other when they don't share a from frame:div {
animation-name: move1, move2;
}@keyframes move1 {
to {
left: 256px;
}
}
@keyframes move2 {
to {
top: 256px;
}
}@keyframes move1 {
to {
transform: translateX(256px);
}
}
@keyframes move2 {
to {
transform: translateY(256px);
}
}move2 lacks a from frame, so the starting position is animated by move1. The resulting path is not a straight line — it's a quadratic Bézier curve, because the element's position is a weighted average of two simultaneously linearly-moving points.p0, p1, and p2, you can define the animations like this:/* pseudo-CSS code */
div {
animation-name: move1, move2;
}
@keyframes move1 {
from {
transform: translate3d(p0.x, p0.y, p0.z);
}
/* define q1 = (2 * p1 - p2) */
to {
transform: translate3d(q1.x, q1.y, q1.z);
}
}
@keyframes move2 {
to {
transform: translate3d(p2.x, p2.y, p2.z);
}
}p0, p1, p2, p3) by chaining three animations:/* pseudo-CSS code */
div {
animation-name: move1, move2, move3;
}
@keyframes move1 {
from {
transform: translate3d(p0.x, p0.y, p0.z);
}
/* define q1 = (3 * p1 - 3 * p2 + p3) */
to {
transform: translate3d(q1.x, q1.y, q1.z);
}
}
@keyframes move2 {
/* define q2 = (3 * p2 - 2 * p3) */
to {
transform: translate3d(q2.x, q2.y, q2.z);
}
}
@keyframes move3 {
to {
transform: translate3d(p3.x, p3.y, p3.z);
}
}z values. Higher-order Bézier curves would likely follow the same recursive pattern, though no generic formula is presented here.Method 3: Direct Construction
p0 through p3, intermediate points are constructed by linear interpolation between the original points:
@keyframes green0 {
from {
--green0x: var(--p0x);
--green0y: var(--p0y);
}
to {
--green0x: var(--p1x);
--green0y: var(--p1y);
}
}@keyframes blue0 {
from {
--blue0x: var(--green0x);
--blue0y: var(--green0y);
}
to {
--blue0x: var(--green1x);
--blue0y: var(--green1y);
}
}@property to register the custom properties as colors or lengths, you can animate the entire construction. Nested elements then trace out the final Bézier curve based on those variable values.@property is not yet supported in every browser, so the technique has compatibility constraints.Comparing Animation Timing
t changes uniformly with animation progress.animation-timing-function can be applied to the composing animations, but it must be the same function applied to all of them simultaneously. This allows easing, steps, and any other timing function.cubic-bezier() with the first two parameters fixed by the curve itself. What you can do is adjust the vertical positions of the control points (the second and fourth parameters). Surprisingly, these adjustments map directly onto the more flexible timing capabilities of Method 2:
cubic-bezier(u1, *, u2, *).animation-timing-function: cubic-bezier(u1, 1/3, u2, 2/3).move-x and move-y animations must share the same u1 and u2 values. This explains why Method 1 is limited: unlike the other approaches, you can't use steps() or arbitrary timing curves — only cubic-bezier() with two free parameters.Summarizing the Trade-offs
animation-timing-function, but timing options are restricted and it breaks for axis-aligned curves or orders above three.@property variables, though variable support and browser compatibility may be a sticking point.



