When Height Animations Aren’t an Option
Animating height or width forces the browser to recalculate layout and paint on every frame, which makes smooth 60 FPS animation nearly impossible. Compositor-friendly properties like transform and opacity avoid those costs, but they bring their own problem: simply scaling an element distorts its contents. Everything inside gets stretched or squeezed, which looks nothing like a genuine expand/collapse transition.
One workaround that has held up well is the technique of measuring the content with JavaScript and using requestAnimationFrame to drive a height transition. It works, and it's been production-proven in UI component libraries. But there's an alternative approach that generates custom CSS keyframes on the fly, enabling transform-based animation without the skew effect. The key insight, borrowed from a technique popularized by Paul Lewis, is to step through an animation from 0 to 100 percent, calculating the precise scale values needed for the element and its contents at each frame. Those values are then baked into a string and injected into the page as a style element.
Step 1: Establish the Scale Range
The first task is figuring out the correct scale values for both the start and end states. You measure a proxy element with getBoundingClientRect() and divide that by the dimensions of the end state:
function calculateStartScale () {
const start= startElement.getBoundingClientRect();
const end= endElement.getBoundingClientRect();
return {
x: start.width / end.width,
y: start.height / end.height
};
}
Step 2: Generate the Keyframes
Next, run a loop that iterates over the number of frames you want—at least 60 to guarantee smoothness. At each iteration, compute the easing value with an easing function:
function ease (v, pow=4) {
return 1 - Math.pow(1 - v, pow);
}
let easedStep = ease(i / frame);
Use that eased value to derive the element's scale at the current step:
const xScale = x + (1 - x) * easedStep;
const yScale = y + (1 - y) * easedStep;
Then append the step to the animation string:
animation += `${step}% {
transform: scale(${xScale}, ${yScale});
}`;
The critical part is keeping the content from getting distorted. For each scale step, apply an inverted counter-animation to the content layer:
const invXScale = 1 / xScale;
const invYScale = 1 / yScale;
inverseAnimation += `${step}% {
transform: scale(${invXScale}, ${invYScale});
}`;
When the loop completes, you can return the full animation strings or inject them directly into a new style tag.
Step 3: Wire Up the CSS Animations
On the stylesheet side, the animations just need to be enabled on the proper elements:
.element--expanded {
animation-name: animation;
animation-duration: 300ms;
animation-timing-function: step-end;
}
.element-contents--expanded {
animation-name: inverseAnimation ;
animation-duration: 300ms;
animation-timing-function: step-end;
}
Applying the Technique to an Expandable Section
This method adapts well to an expandable section component. In that case, you're only worried about the vertical axis—getting the Y value from the collapsed title state and the full height from the expanded section:
_calculateScales () {
var collapsed = this._sectionItemTitle.getBoundingClientRect();
var expanded = this._section.getBoundingClientRect();
// create css variable with collapsed height, to apply on the wrapper
this._sectionWrapper.style.setProperty('--title-height', collapsed.height + 'px');
this._collapsed = {
y: collapsed.height / expanded.height
}
}
Because the expanded section uses absolute positioning (so it doesn't take up space when collapsed), the CSS variable for the collapsed height is applied to a wrapper. That wrapper is the only element with relative positioning.
The keyframe creation function follows the same pattern as before, but it needs to produce four separate animations:
- An expand animation for the wrapper
- A counter-expand animation for the content
- A collapse animation for the wrapper
- A counter-collapse animation for the content
The loop runs 60 iterations to reach a 60 FPS result, computing an eased percentage at each step and pushing it into the final animation strings:
outerAnimation.push(`
${percentage}% {
transform: scaleY(${yScale});
}`);
innerAnimation.push(`
${percentage}% {
transform: scaleY(${invScaleY});
}`);
Since this is built as a constructor to support multiple patterns, all generated animations should live in one shared stylesheet. Check whether the style element already exists; if not, create it with a meaningful class name. Otherwise, each expandable section would spawn its own style tag, which isn't ideal.
var sectionEase = document.querySelector('.section-animations');
if (!sectionEase) {
sectionEase = document.createElement('style');
sectionEase.classList.add('section-animations');
}
That raises a valid question: if multiple expandable sections exist, won't they all reference the same-named animation with potentially wrong values for their own content?
Yes, they would. The solution is to generate unique animation names dynamically. Each section gets an index from the querySelectorAll('.section') loop, which is appended to the animation name:
var sectionExpandAnimationName = "sectionExpandAnimation" + index;
var sectionExpandContentsAnimationName = "sectionExpandContentsAnimation" + index;
That unique name is stored in a CSS variable scoped to the current expandable section. In the CSS, the animation property simply references that variable, so each pattern gets its own correct animation-name:
.section.is--expanded {
animation-name: var(--sectionExpandAnimation);
}
.is--expanded .section-item {
animation-name: var(--sectionExpandContentsAnimation);
}
.section.is--collapsed {
animation-name: var(--sectionCollapseAnimation);
}
.is--collapsed .section-item {
animation-name: var(--sectionCollapseContentsAnimation);
}
The rest of the JavaScript handles event listeners, toggle functionality, and accessibility improvements.
HTML and CSS Considerations
The markup requires an extra wrapper to serve as the non-animating relative element. The expandable children are positioned absolute so they occupy no space when collapsed. And because the counter-animation is in play, the content should be scaled to full size to avoid the skew effect:
.section-item-wrapper {
min-height: var(--title-height);
position: relative;
}
.section {
animation-duration: 300ms;
animation-timing-function: step-end;
contain: content;
left: 0;
position: absolute;
top: 0;
transform-origin: top left;
will-change: transform;
}
.section-item {
animation-duration: 300ms;
animation-timing-function: step-end;
contain: content;
transform-origin: top left;
will-change: transform;
}
A few CSS details matter for this to work correctly:
- The
animation-timing-functionshould be set tolinearorstep-end. Without that, the browser applies easing between each generated keyframe. will-changeenables GPU acceleration for the transform animation, yielding smoother performance.- The
containproperty with a value ofcontentslets the browser treat the element independently from the rest of the DOM, limiting the area it must check when recalculating layout, style, and paint. visibilityandopacityare used to hide content from both visual display and screen readers when collapsed.
.section-item-content {
opacity: 1;
transition: opacity 500ms ease;
}
.is--collapsed .section-item-content {
opacity: 0;
visibility: hidden;
}
Performance Verification
Checking the results in DevTools' Performance tab (Chrome in this case) confirms the approach is sound. The FPS meter consistently reaches the 60 FPS mark, even under heavy repetitive use.


Final Takeaways
This isn't a universal replacement for every other animation method, and it shouldn't be treated as one. It's another solid option to weigh against the alternatives, and the right choice depends on the specific use case.
The technique has clear strengths. Generating the keyframes takes real effort up front, but that work happens only once at page load. All user interactions after that are reduced to toggling classes and attributes, which is an attractive performance profile.
There are limitations, too. The counter-scale approach works best with absolutely positioned or off-canvas elements like floating action buttons and menus. Borders are also tricky to handle because the method relies on overflow: hidden. Still, the potential here is substantial, and it's worth adding to any frontend developer's toolkit.



