Skip to content

Commit 12f72fe

Browse files
committed
Create <BounceCards /> component
1 parent 53c5b5e commit 12f72fe

File tree

5 files changed

+458
-1
lines changed

5 files changed

+458
-1
lines changed

src/constants/Categories.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ export const CATEGORIES = [
8080
'Flowing Menu',
8181
'Elastic Slider',
8282
'Stack',
83-
'Chroma Grid'
83+
'Chroma Grid',
84+
'Bounce Cards'
8485
]
8586
},
8687
{

src/constants/Components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const components = {
6969
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
7070
'stack': () => import('../demo/Components/StackDemo.vue'),
7171
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
72+
'bounce-cards': () => import('../demo/Components/BounceCardsDemo.vue'),
7273
};
7374

7475
const backgrounds = {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import code from '@content/Components/BounceCards/BounceCards.vue?raw';
2+
import { createCodeObject } from '@/types/code';
3+
4+
export const bounceCards = createCodeObject(code, 'Components/BounceCards', {
5+
installation: `npm install gsap`,
6+
usage: `<template>
7+
<BounceCards
8+
:images="images"
9+
:container-width="500"
10+
:container-height="250"
11+
:animation-delay="0.5"
12+
:animation-stagger="0.06"
13+
ease-type="elastic.out(1, 0.8)"
14+
:transform-styles="transformStyles"
15+
:enable-hover="true"
16+
class="custom-bounce-cards"
17+
/>
18+
</template>
19+
20+
<script setup lang="ts">
21+
import BounceCards from "./BounceCards.vue";
22+
23+
const images = [
24+
'https://picsum.photos/400/400?grayscale',
25+
'https://picsum.photos/500/500?grayscale',
26+
'https://picsum.photos/600/600?grayscale',
27+
'https://picsum.photos/700/700?grayscale',
28+
'https://picsum.photos/300/300?grayscale'
29+
];
30+
31+
const transformStyles = [
32+
'rotate(5deg) translate(-150px)',
33+
'rotate(0deg) translate(-70px)',
34+
'rotate(-5deg)',
35+
'rotate(5deg) translate(70px)',
36+
'rotate(-5deg) translate(150px)'
37+
];
38+
</script>`
39+
});
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
<template>
2+
<div
3+
:class="['bounceCardsContainer', className]"
4+
:style="{
5+
position: 'relative',
6+
width: typeof containerWidth === 'number' ? `${containerWidth}px` : containerWidth,
7+
height: typeof containerHeight === 'number' ? `${containerHeight}px` : containerHeight,
8+
display: 'flex',
9+
justifyContent: 'center',
10+
alignItems: 'center'
11+
}"
12+
>
13+
<div
14+
v-for="(src, idx) in images"
15+
:key="idx"
16+
:class="`card card-${idx}`"
17+
:style="{
18+
position: 'absolute',
19+
width: '200px',
20+
aspectRatio: '1',
21+
border: '5px solid #fff',
22+
borderRadius: '25px',
23+
overflow: 'hidden',
24+
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.2)',
25+
transform: transformStyles[idx] ?? 'none',
26+
backgroundColor: '#f8f9fa'
27+
}"
28+
@mouseenter="() => pushSiblings(idx)"
29+
@mouseleave="resetSiblings"
30+
>
31+
<div
32+
v-if="!imageLoaded[idx]"
33+
class="placeholder"
34+
:style="{
35+
position: 'absolute',
36+
top: '0',
37+
left: '0',
38+
width: '100%',
39+
height: '100%',
40+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
41+
display: 'flex',
42+
alignItems: 'center',
43+
justifyContent: 'center',
44+
zIndex: 1
45+
}"
46+
>
47+
<div
48+
class="loading-spinner"
49+
:style="{
50+
width: '75px',
51+
height: '75px',
52+
border: '3px solid #a3a3a3',
53+
borderTop: '3px solid #27FF64',
54+
borderRadius: '50%',
55+
}"
56+
></div>
57+
</div>
58+
59+
<img
60+
class="image"
61+
:src="src"
62+
:alt="`card-${idx}`"
63+
:style="{
64+
position: 'absolute',
65+
top: '0',
66+
left: '0',
67+
width: '100%',
68+
height: '100%',
69+
objectFit: 'cover',
70+
opacity: imageLoaded[idx] ? 1 : 0,
71+
transition: 'opacity 0.3s ease',
72+
zIndex: 2
73+
}"
74+
@load="() => onImageLoad(idx)"
75+
@error="() => onImageError(idx)"
76+
/>
77+
</div>
78+
</div>
79+
</template>
80+
81+
<script setup lang="ts">
82+
import { onMounted, onUnmounted, ref } from 'vue';
83+
import { gsap } from 'gsap';
84+
85+
export interface BounceCardsProps {
86+
className?: string;
87+
images?: string[];
88+
containerWidth?: number | string;
89+
containerHeight?: number | string;
90+
animationDelay?: number;
91+
animationStagger?: number;
92+
easeType?: string;
93+
transformStyles?: string[];
94+
enableHover?: boolean;
95+
}
96+
97+
const props = withDefaults(defineProps<BounceCardsProps>(), {
98+
className: '',
99+
images: () => [],
100+
containerWidth: 400,
101+
containerHeight: 400,
102+
animationDelay: 0.5,
103+
animationStagger: 0.06,
104+
easeType: 'elastic.out(1, 0.8)',
105+
transformStyles: () => [
106+
'rotate(10deg) translate(-170px)',
107+
'rotate(5deg) translate(-85px)',
108+
'rotate(-3deg)',
109+
'rotate(-10deg) translate(85px)',
110+
'rotate(2deg) translate(170px)'
111+
],
112+
enableHover: true
113+
});
114+
115+
const imageLoaded = ref(new Array(props.images.length).fill(false));
116+
117+
const getNoRotationTransform = (transformStr: string): string => {
118+
const hasRotate = /rotate\([\s\S]*?\)/.test(transformStr);
119+
if (hasRotate) {
120+
return transformStr.replace(/rotate\([\s\S]*?\)/, 'rotate(0deg)');
121+
} else if (transformStr === 'none') {
122+
return 'rotate(0deg)';
123+
} else {
124+
return `${transformStr} rotate(0deg)`;
125+
}
126+
};
127+
128+
const getPushedTransform = (baseTransform: string, offsetX: number): string => {
129+
const translateRegex = /translate\(([-0-9.]+)px\)/;
130+
const match = baseTransform.match(translateRegex);
131+
if (match) {
132+
const currentX = parseFloat(match[1]);
133+
const newX = currentX + offsetX;
134+
return baseTransform.replace(translateRegex, `translate(${newX}px)`);
135+
} else {
136+
return baseTransform === 'none'
137+
? `translate(${offsetX}px)`
138+
: `${baseTransform} translate(${offsetX}px)`;
139+
}
140+
};
141+
142+
const pushSiblings = (hoveredIdx: number) => {
143+
if (!props.enableHover) return;
144+
145+
props.images.forEach((_, i) => {
146+
gsap.killTweensOf(`.card-${i}`);
147+
148+
const baseTransform = props.transformStyles[i] || 'none';
149+
150+
if (i === hoveredIdx) {
151+
const noRotationTransform = getNoRotationTransform(baseTransform);
152+
gsap.to(`.card-${i}`, {
153+
transform: noRotationTransform,
154+
duration: 0.4,
155+
ease: 'back.out(1.4)',
156+
overwrite: 'auto'
157+
});
158+
} else {
159+
const offsetX = i < hoveredIdx ? -160 : 160;
160+
const pushedTransform = getPushedTransform(baseTransform, offsetX);
161+
162+
const distance = Math.abs(hoveredIdx - i);
163+
const delay = distance * 0.05;
164+
165+
gsap.to(`.card-${i}`, {
166+
transform: pushedTransform,
167+
duration: 0.4,
168+
ease: 'back.out(1.4)',
169+
delay,
170+
overwrite: 'auto'
171+
});
172+
}
173+
});
174+
};
175+
176+
const resetSiblings = () => {
177+
if (!props.enableHover) return;
178+
179+
props.images.forEach((_, i) => {
180+
gsap.killTweensOf(`.card-${i}`);
181+
const baseTransform = props.transformStyles[i] || 'none';
182+
gsap.to(`.card-${i}`, {
183+
transform: baseTransform,
184+
duration: 0.4,
185+
ease: 'back.out(1.4)',
186+
overwrite: 'auto'
187+
});
188+
});
189+
};
190+
191+
const onImageLoad = (idx: number) => {
192+
imageLoaded.value[idx] = true;
193+
};
194+
195+
const onImageError = (idx: number) => {
196+
imageLoaded.value[idx] = true;
197+
};
198+
199+
onMounted(() => {
200+
gsap.fromTo(
201+
'.card',
202+
{ scale: 0 },
203+
{
204+
scale: 1,
205+
stagger: props.animationStagger,
206+
ease: props.easeType,
207+
delay: props.animationDelay
208+
}
209+
);
210+
});
211+
212+
onUnmounted(() => {
213+
gsap.killTweensOf('.card');
214+
props.images.forEach((_, i) => {
215+
gsap.killTweensOf(`.card-${i}`);
216+
});
217+
});
218+
</script>
219+
220+
<style scoped>
221+
.bounceCardsContainer {
222+
position: relative;
223+
display: flex;
224+
justify-content: center;
225+
align-items: center;
226+
width: 400px;
227+
height: 400px;
228+
}
229+
230+
.card {
231+
position: absolute;
232+
width: 200px;
233+
aspect-ratio: 1;
234+
border: 5px solid #fff;
235+
border-radius: 25px;
236+
overflow: hidden;
237+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
238+
background-color: transparent !important;
239+
}
240+
241+
.card .image {
242+
width: 100%;
243+
height: 100%;
244+
object-fit: cover;
245+
}
246+
247+
.loading-spinner {
248+
animation: spin 1s linear infinite;
249+
}
250+
@keyframes spin {
251+
0% { transform: rotate(0deg);}
252+
100% { transform: rotate(360deg);}
253+
}
254+
</style>

0 commit comments

Comments
 (0)