mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Create <BounceCards /> component
This commit is contained in:
254
src/content/Components/BounceCards/BounceCards.vue
Normal file
254
src/content/Components/BounceCards/BounceCards.vue
Normal 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>
|
||||
Reference in New Issue
Block a user