Scaling Duration by Distance and Size
Part of Timing Functions & Easing Curves in Core CSS Animation Fundamentals.
The problem
A kanban board animates cards to their new positions with a fixed 300ms transition. Moving a card one slot down looks slow and floaty. Moving a card from the first column to the last on a wide screen looks like it teleports, because it crosses 1,200 pixels in the same 300ms. A bottom sheet and a small tooltip share the same entrance duration, and the sheet feels rushed while the tooltip feels lazy.
In the physical world heavier objects and longer journeys take longer. Motion that ignores distance and size feels uniform in time and inconsistent in speed.
Root cause analysis: speed is distance over time
A fixed duration fixes time, so speed varies with distance. A 40px move at 300ms averages 133px per second; a 1,200px move averages 4,000px per second. Human perception is closer to judging speed, so the short move feels slow and the long one feels abrupt.
A fixed speed has the opposite problem: long moves become very long. A 1,200px move at 400px per second takes three seconds, which is far too slow for an interface.
Sub-linear scaling sits between the two. Growing duration with the square root of distance keeps short moves quick and makes long moves take longer, but not proportionally longer. Material-style guidance and many motion systems use variations of this idea: duration increases with distance, with diminishing increments.
Size follows the same logic. Larger elements displace more of the screen, and a large surface covering the viewport in 150ms feels violent, while a 16px badge taking 400ms feels sluggish. Duration tiers by element size — small, medium, large, full-screen — are the static version of the same principle.
Clamping is essential. Whatever the formula, set a minimum so tiny moves remain perceptible and a maximum so nothing keeps the user waiting. A range of roughly 150ms to 450ms covers most interface motion.
Step-by-step resolution
Production code pattern
:root {
--dur-min: 150ms;
--dur-max: 450ms;
--dur-rate: 8ms; /* ms per sqrt(px) */
}
.card {
/* --distance is unitless pixels, written by script before the move. */
--dur: clamp(
var(--dur-min),
calc(var(--dur-min) + sqrt(var(--distance, 0)) * var(--dur-rate)),
var(--dur-max)
);
transition: translate var(--dur) cubic-bezier(0.4, 0, 0.2, 1);
}
/* Size tiers for entrances where distance is implied by size. */
.tooltip { --enter: 120ms; }
.menu { --enter: 200ms; }
.bottom-sheet { --enter: 320ms; }
.fullscreen { --enter: 400ms; }
:is(.tooltip, .menu, .bottom-sheet, .fullscreen)[data-open] {
transition: opacity var(--enter) ease-out, translate var(--enter) cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
:is(.tooltip, .menu, .bottom-sheet, .fullscreen)[data-open] {
transition: opacity 120ms ease-out;
}
}
// FLIP-style move: measure, write distance, then let the transition run.
function moveCard(card, newParent) {
const before = card.getBoundingClientRect();
newParent.append(card);
const after = card.getBoundingClientRect();
const dx = before.left - after.left;
const dy = before.top - after.top;
card.style.setProperty('--distance', String(Math.hypot(dx, dy)));
card.style.transition = 'none';
card.style.translate = `${dx}px ${dy}px`; // appear at the old spot
requestAnimationFrame(() => {
card.style.transition = '';
card.style.translate = ''; // transition to the new spot
});
}
Rendering Impact: composite for the move. The duration is resolved when the transition starts from a static custom property; per frame, only
translatechanges. The two layout reads happen once per move, before any writes.
sqrt() is one of the newer CSS math functions. Where it is unsupported the whole --dur computation is invalid and the transition falls back to its initial duration of 0 — an instant move. Guard it with @supports (width: calc(sqrt(4) * 1px)) and a fixed-duration default, or compute the duration in script and write it directly.
Verification checklist
Constraints and trade-offs
- Distance-scaled durations make simultaneous moves finish at different times, which can look disorganised in grids; consider using the longest duration for a group.
- The rate constant needs tuning per product; start around 6 to 10ms per square-root pixel.
- High-density and large screens produce longer distances in CSS pixels for the same visual movement.
- Measuring positions forces layout; batch measurements for many elements.
- Designers may prefer tiered durations over formulas for predictability.
Frequently asked questions
Should longer animations always take longer?
Somewhat longer, not proportionally. Growing duration with the square root of distance keeps long moves from feeling abrupt without making them slow.
What minimum and maximum durations should I use?
Around 150ms minimum so motion is perceptible, and 400 to 500ms maximum for interface motion so users are not kept waiting.
Can CSS calculate duration from distance on its own?
CSS cannot measure distance, but it can compute duration from a custom property set by script using calc(), sqrt() and clamp().
How do size tiers relate to distance scaling?
They are the static version of the same idea. When an element enters from off-screen, its size stands in for the distance it travels.
Related
- Timing Functions & Easing Curves — the parent topic
- FLIP Technique for Layout Animations — the measurement pattern used above
- Choosing Safe Animation Durations and Thresholds — accessibility limits on duration and distance