Custom Properties and cubic-bezier() Are an Animation Engine
The combination of CSS custom properties and cubic-bezier() enables transition effects that rival keyframe-based animations. The core pattern registers numeric custom properties with @property, gives each one its own timing function, and then sums them in a calc() expression that drives a real property. Since the registered variables animate over the same duration with different easing curves, the mathematical sum of those curves becomes the effective transition you see.
@property is currently limited to Chromium-based browsers, so the demos discussed here are best viewed there. That said, the technique is worth exploring now because it opens a different way of thinking about transitions entirely.
A Transition from Two Added Values
The starting point is two custom properties, each animated with its own timing function:
@property --d1 {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@property --d2 {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
.box {
top: calc((var(--d1) + var(--d2)) * 1%);
transition:
--d1 1s cubic-bezier(0.7, 1200, 0.3, -1200),
--d2 1s cubic-bezier(0.5, 1200, 0.5, -1200);
}
.box:hover {
--d1: 0.2;
--d1: -0.2;
}
This code defines --d1 and --d2 as <number> types that reset to 0. The top property on .box is set to calc(var(--d1) * 1% + var(--d2) * 1%). Multiplying by 1% converts the unitless values into percentages. Using unitless numbers rather than <percentage> types from the start adds flexibility for more complex formulas later.
When the box is hovered, both variables change value. Each variable is given its own transition, specifically a distinct cubic-bezier curve, but the same duration. What appears on screen is the value of top, which is the sum of both animated variables. Because the two timing functions are different, the summed output is their combination — not a linear blend of the two paths, but a motion path that includes both curves' behavior at every instant.
A quick illustration: animate one variable with one curve, the other with a different curve, then set top to their sum. The first two transitions show the individual variables. The third transitions to the sum of the values at every step, producing a new and sometimes unexpected path.
This generic technique does not have to involve cubic-bezier() at all. If --d1 goes from 0 to 30 with ease-in and --d2 goes from 0 to -20 with ease-out, then top transitions from 0 to 10, with a timing function that is the sum of ease-in and ease-out. The result is not a fancy composite curve in that case, but it confirms that the approach works with any timing function, not just bespoke cubic-bezier values.
Interactive Curve Construction
An interactive demo provides two cubic-bezier curves as the foundation. Tweaking their control points changes each individual path, and the transition for top is the generated composite of both. Some examples show the sort of control this yields:





These curve combinations demonstrate that two separate timing functions can sum into a third that is complex enough to drive the type of motion normally associated with keyframe sequences. Combining two opposite curves yields a flat line: if one moves the value up at the same rate another moves it down, the net property value never changes. There is no transition at all, which is itself a demonstration of the math holding up.
Three or More Variables and Other Operations
Two variables are often all that is needed for an interesting result, but the pattern scales to N variables. Define each custom property, give it its own timing function and update its value, and sum them all inside the target property declaration.
The output function is not strictly a sum. Subtract, multiply, or divide the variables inside the calc() expression to get other families of curves:


Here the variables are multiplied. A variable multiplied by itself creates a quadratic curve. Introduce min() and max() to the calculation as a way to emulate an absolute value function:


In the box maintained by those two blocks, top never crosses the center point on the y-axis, since it is always positive. A margin-top adjustment makes the element's center the zero reference. The general code pattern can be formalized this way:
@property --d1 { /* we do the same for d2 .. dn */
syntax: '<number>';
inherits: false;
initial-value: i1; /* the initial value can be different for each variable */
}
.box {
--duration: 1s; /* the same duration for all */
property: calc(f(var(--d1),var(--d2), .. ,var(--dn))*[1UNIT]);
transition:
--d1 var(--duration) cubic-bezier( ... ),
--d2 var(--duration) cubic-bezier( ... ),
/* .. */
--dn var(--duration) cubic-bezier( ... );
}
.box:hover {
--d1:f1;
--d2:f2;
/* .. */
--dn:f3;
}
That pseudocode explains the choreography:
- Declare each numeric variable with
@propertyand an initial value. - Give each variable its own timing function but a shared duration.
- Build a formula
ffrom the variables that produces the final number, then convert that to the target unit insidecalc(). - Change each variable's value on hover or state toggle.
The property then animates from f(i1,i2,…,in) to f(f1,f2,…,fn) with the composed timing function.
Chaining via transition-delay
Combining timing functions is one way to generate complex output. Chaining them is the alternate route — give each variable a different transition-delay. When the transitions fire sequentially rather than simultaneously, the sum at any point is a function added to a constant value. Mathematically it is still a sum, but the output simulates a delay that preserves each curve before the next one begins.
With N variables, each incrementally offset, the result becomes a layered timeline rather than a single composed path. That is enough to build a small animation scene from a single element, including effects like a natural pendulum swing:
Along the same lines, a ball that bounces naturally and a ball rolling along a curved path both render convincingly with precisely this method.
The Three Building Blocks
The full technique relies on three principles:
cubic-bezier()is able to reproduce parabolic and sinusoidal behavior, making complex transitions unnecessary in specific cases.- Custom properties combined in
calc()compose individual timing functions into a composite that works for arbitrary motion paths. - A staggered
transition-delaychains the curves into a structured timeline.
These three mechanics in combination mean the absence of @keyframes is never a limitation for building intricate motion.



