I slightly changed @kostyfisik's solution by adding width variation, so you can choose which measurement to animate with mode props.
<script setup lang="ts">
interface IExpandAnimationProps {
duration?: number;
easingEnter?: string;
easingLeave?: string;
opacityClosed?: number;
opacityOpened?: number;
mode?: 'width' | 'height';
}
interface initialStyle {
height: string;
width: string;
position: string;
visibility: string;
overflow: string;
paddingTop: string;
paddingBottom: string;
paddingLeft: string;
paddingRight: string;
borderTopWidth: string;
borderBottomWidth: string;
borderLeftWidth: string;
borderRightWidth: string;
marginTop: string;
marginBottom: string;
marginLeft: string;
marginRight: string;
}
const props = withDefaults(defineProps<IExpandAnimationProps>(), {
duration: 300,
easingEnter: 'ease-in-out',
easingLeave: 'ease-in-out',
opacityClosed: 0,
opacityOpened: 1,
mode: 'height',
});
const closed = '0px';
function getElementStyle(element: HTMLElement): initialStyle {
return {
height: element.style.height,
width: element.style.width,
position: element.style.position,
visibility: element.style.visibility,
overflow: element.style.overflow,
paddingTop: element.style.paddingTop,
paddingBottom: element.style.paddingBottom,
paddingLeft: element.style.paddingLeft,
paddingRight: element.style.paddingRight,
borderTopWidth: element.style.borderTopWidth,
borderBottomWidth: element.style.borderBottomWidth,
borderLeftWidth: element.style.borderLeftWidth,
borderRightWidth: element.style.borderRightWidth,
marginTop: element.style.marginTop,
marginBottom: element.style.marginBottom,
marginLeft: element.style.marginLeft,
marginRight: element.style.marginRight,
};
}
function prepareElement(element: HTMLElement, initialStyle: initialStyle): string {
let width, height;
if (props.mode === 'height') {
element.style.width = getComputedStyle(element).width;
element.style.position = 'absolute';
element.style.visibility = 'hidden';
element.style.height = '';
height = getComputedStyle(element).height;
element.style.width = initialStyle.width;
element.style.position = initialStyle.position;
element.style.visibility = initialStyle.visibility;
element.style.height = closed;
element.style.overflow = 'hidden';
return initialStyle.height && initialStyle.height != closed ? initialStyle.height : height;
} else {
element.style.height = getComputedStyle(element).height;
element.style.position = 'absolute';
element.style.visibility = 'hidden';
element.style.width = '';
width = getComputedStyle(element).width;
element.style.height = initialStyle.height;
element.style.position = initialStyle.position;
element.style.visibility = initialStyle.visibility;
element.style.width = closed;
element.style.overflow = 'hidden';
return initialStyle.width && initialStyle.width != closed ? initialStyle.width : width;
}
}
function animateTransition(
element: HTMLElement,
initialStyle: initialStyle,
done: () => void,
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: number | KeyframeAnimationOptions,
): void {
const animation = element.animate(keyframes, options);
if (props.mode === 'height') {
element.style.height = initialStyle.height;
} else {
element.style.width = initialStyle.width;
}
animation.onfinish = () => {
element.style.overflow = initialStyle.overflow;
done();
};
}
function getEnterKeyframes(measurement: string, initialStyle: initialStyle): Keyframe[] {
if (props.mode === 'height') {
return [
{
height: closed,
opacity: props.opacityClosed,
paddingTop: closed,
paddingBottom: closed,
borderTopWidth: closed,
borderBottomWidth: closed,
marginTop: closed,
marginBottom: closed,
},
{
height: measurement,
opacity: props.opacityOpened,
paddingTop: initialStyle.paddingTop,
paddingBottom: initialStyle.paddingBottom,
borderTopWidth: initialStyle.borderTopWidth,
borderBottomWidth: initialStyle.borderBottomWidth,
marginTop: initialStyle.marginTop,
marginBottom: initialStyle.marginBottom,
},
];
} else {
return [
{
width: closed,
opacity: props.opacityClosed,
paddingLeft: closed,
paddingRight: closed,
borderLeftWidth: closed,
borderRightWidth: closed,
marginLeft: closed,
marginRight: closed,
},
{
width: measurement,
opacity: props.opacityOpened,
paddingLeft: initialStyle.paddingLeft,
paddingRight: initialStyle.paddingRight,
borderLeftWidth: initialStyle.borderLeftWidth,
borderRightWidth: initialStyle.borderRightWidth,
marginLeft: initialStyle.marginLeft,
marginRight: initialStyle.marginRight,
},
];
}
}
function enterTransition(element: Element, done: () => void) {
const HTMLElement = element as HTMLElement;
const initialStyle = getElementStyle(HTMLElement);
const measurement = prepareElement(HTMLElement, initialStyle);
const keyframes = getEnterKeyframes(measurement, initialStyle);
const options = { duration: props.duration, easing: props.easingEnter };
animateTransition(HTMLElement, initialStyle, done, keyframes, options);
}
function leaveTransition(element: Element, done: () => void) {
const HTMLElement = element as HTMLElement;
const initialStyle = getElementStyle(HTMLElement);
let measurement;
if (props.mode === 'height') {
measurement = getComputedStyle(HTMLElement).height;
HTMLElement.style.height = measurement;
} else {
measurement = getComputedStyle(HTMLElement).width;
HTMLElement.style.width = measurement;
}
HTMLElement.style.overflow = 'hidden';
const keyframes = getEnterKeyframes(measurement, initialStyle).reverse();
const options = { duration: props.duration, easing: props.easingLeave };
animateTransition(HTMLElement, initialStyle, done, keyframes, options);
}
</script>
<template>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<slot />
</Transition>
</template>