Create <BounceCards /> component

This commit is contained in:
Alfarish Fizikri
2025-07-23 22:17:01 +07:00
parent 53c5b5e208
commit 12f72fe164
5 changed files with 458 additions and 1 deletions

View File

@@ -0,0 +1,254 @@
<template>
<div
:class="['bounceCardsContainer', className]"
:style="{
position: 'relative',
width: typeof containerWidth === 'number' ? `${containerWidth}px` : containerWidth,
height: typeof containerHeight === 'number' ? `${containerHeight}px` : containerHeight,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}"
>
<div
v-for="(src, idx) in images"
:key="idx"
:class="`card card-${idx}`"
:style="{
position: 'absolute',
width: '200px',
aspectRatio: '1',
border: '5px solid #fff',
borderRadius: '25px',
overflow: 'hidden',
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.2)',
transform: transformStyles[idx] ?? 'none',
backgroundColor: '#f8f9fa'
}"
@mouseenter="() => pushSiblings(idx)"
@mouseleave="resetSiblings"
>
<div
v-if="!imageLoaded[idx]"
class="placeholder"
:style="{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1
}"
>
<div
class="loading-spinner"
:style="{
width: '75px',
height: '75px',
border: '3px solid #a3a3a3',
borderTop: '3px solid #27FF64',
borderRadius: '50%',
}"
></div>
</div>
<img
class="image"
:src="src"
:alt="`card-${idx}`"
:style="{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: imageLoaded[idx] ? 1 : 0,
transition: 'opacity 0.3s ease',
zIndex: 2
}"
@load="() => onImageLoad(idx)"
@error="() => onImageError(idx)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { gsap } from 'gsap';
export interface BounceCardsProps {
className?: string;
images?: string[];
containerWidth?: number | string;
containerHeight?: number | string;
animationDelay?: number;
animationStagger?: number;
easeType?: string;
transformStyles?: string[];
enableHover?: boolean;
}
const props = withDefaults(defineProps<BounceCardsProps>(), {
className: '',
images: () => [],
containerWidth: 400,
containerHeight: 400,
animationDelay: 0.5,
animationStagger: 0.06,
easeType: 'elastic.out(1, 0.8)',
transformStyles: () => [
'rotate(10deg) translate(-170px)',
'rotate(5deg) translate(-85px)',
'rotate(-3deg)',
'rotate(-10deg) translate(85px)',
'rotate(2deg) translate(170px)'
],
enableHover: true
});
const imageLoaded = ref(new Array(props.images.length).fill(false));
const getNoRotationTransform = (transformStr: string): string => {
const hasRotate = /rotate\([\s\S]*?\)/.test(transformStr);
if (hasRotate) {
return transformStr.replace(/rotate\([\s\S]*?\)/, 'rotate(0deg)');
} else if (transformStr === 'none') {
return 'rotate(0deg)';
} else {
return `${transformStr} rotate(0deg)`;
}
};
const getPushedTransform = (baseTransform: string, offsetX: number): string => {
const translateRegex = /translate\(([-0-9.]+)px\)/;
const match = baseTransform.match(translateRegex);
if (match) {
const currentX = parseFloat(match[1]);
const newX = currentX + offsetX;
return baseTransform.replace(translateRegex, `translate(${newX}px)`);
} else {
return baseTransform === 'none'
? `translate(${offsetX}px)`
: `${baseTransform} translate(${offsetX}px)`;
}
};
const pushSiblings = (hoveredIdx: number) => {
if (!props.enableHover) return;
props.images.forEach((_, i) => {
gsap.killTweensOf(`.card-${i}`);
const baseTransform = props.transformStyles[i] || 'none';
if (i === hoveredIdx) {
const noRotationTransform = getNoRotationTransform(baseTransform);
gsap.to(`.card-${i}`, {
transform: noRotationTransform,
duration: 0.4,
ease: 'back.out(1.4)',
overwrite: 'auto'
});
} else {
const offsetX = i < hoveredIdx ? -160 : 160;
const pushedTransform = getPushedTransform(baseTransform, offsetX);
const distance = Math.abs(hoveredIdx - i);
const delay = distance * 0.05;
gsap.to(`.card-${i}`, {
transform: pushedTransform,
duration: 0.4,
ease: 'back.out(1.4)',
delay,
overwrite: 'auto'
});
}
});
};
const resetSiblings = () => {
if (!props.enableHover) return;
props.images.forEach((_, i) => {
gsap.killTweensOf(`.card-${i}`);
const baseTransform = props.transformStyles[i] || 'none';
gsap.to(`.card-${i}`, {
transform: baseTransform,
duration: 0.4,
ease: 'back.out(1.4)',
overwrite: 'auto'
});
});
};
const onImageLoad = (idx: number) => {
imageLoaded.value[idx] = true;
};
const onImageError = (idx: number) => {
imageLoaded.value[idx] = true;
};
onMounted(() => {
gsap.fromTo(
'.card',
{ scale: 0 },
{
scale: 1,
stagger: props.animationStagger,
ease: props.easeType,
delay: props.animationDelay
}
);
});
onUnmounted(() => {
gsap.killTweensOf('.card');
props.images.forEach((_, i) => {
gsap.killTweensOf(`.card-${i}`);
});
});
</script>
<style scoped>
.bounceCardsContainer {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 400px;
height: 400px;
}
.card {
position: absolute;
width: 200px;
aspect-ratio: 1;
border: 5px solid #fff;
border-radius: 25px;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
background-color: transparent !important;
}
.card .image {
width: 100%;
height: 100%;
object-fit: cover;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg);}
100% { transform: rotate(360deg);}
}
</style>