Skip to content

Commit e271fb9

Browse files
authored
Merge pull request #59 from Gazoon007/feat/bounce-cards
Create <BounceCards /> component
2 parents b0bfed3 + 63302cb commit e271fb9

File tree

5 files changed

+380
-0
lines changed

5 files changed

+380
-0
lines changed

src/constants/Categories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export const CATEGORIES = [
8282
'Elastic Slider',
8383
'Stack',
8484
'Chroma Grid',
85+
'Bounce Cards',
8586
'Counter',
8687
'Rolling Gallery'
8788
]

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
'counter': () => import('../demo/Components/CounterDemo.vue'),
7374
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),
7475
'scroll-stack': () => import('../demo/Components/ScrollStackDemo.vue'),
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: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<template>
2+
<div
3+
:class="['relative flex items-center justify-center', className]"
4+
:style="{
5+
width: typeof containerWidth === 'number' ? `${containerWidth}px` : containerWidth,
6+
height: typeof containerHeight === 'number' ? `${containerHeight}px` : containerHeight
7+
}"
8+
>
9+
<div
10+
v-for="(src, idx) in images"
11+
:key="idx"
12+
ref="cardRefs"
13+
class="absolute w-[200px] aspect-square border-[5px] border-white rounded-[25px] overflow-hidden shadow-[0_4px_10px_rgba(0,0,0,0.2)] bg-[#f8f9fa] opacity-0"
14+
:style="{ transform: transformStyles[idx] ?? 'none' }"
15+
@mouseenter="() => pushSiblings(idx)"
16+
@mouseleave="resetSiblings"
17+
>
18+
<div v-if="!imageLoaded[idx]" class="absolute inset-0 z-[1] flex items-center justify-center bg-black/80">
19+
<div class="w-[75px] h-[75px] border-[3px] border-gray-400 border-t-[#27FF64] rounded-full animate-spin"></div>
20+
</div>
21+
22+
<img
23+
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ease-in-out z-[2]"
24+
:src="src"
25+
:alt="`card-${idx}`"
26+
:style="{ opacity: imageLoaded[idx] ? 1 : 0 }"
27+
@load="() => onImageLoad(idx)"
28+
@error="() => onImageError(idx)"
29+
/>
30+
</div>
31+
</div>
32+
</template>
33+
34+
<script setup lang="ts">
35+
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
36+
import { gsap } from 'gsap';
37+
38+
export interface BounceCardsProps {
39+
className?: string;
40+
images?: string[];
41+
containerWidth?: number | string;
42+
containerHeight?: number | string;
43+
animationDelay?: number;
44+
animationStagger?: number;
45+
easeType?: string;
46+
transformStyles?: string[];
47+
enableHover?: boolean;
48+
}
49+
50+
const props = withDefaults(defineProps<BounceCardsProps>(), {
51+
className: '',
52+
images: () => [],
53+
containerWidth: 400,
54+
containerHeight: 400,
55+
animationDelay: 0.5,
56+
animationStagger: 0.06,
57+
easeType: 'elastic.out(1, 0.8)',
58+
transformStyles: () => [
59+
'rotate(10deg) translate(-170px)',
60+
'rotate(5deg) translate(-85px)',
61+
'rotate(-3deg)',
62+
'rotate(-10deg) translate(85px)',
63+
'rotate(2deg) translate(170px)'
64+
],
65+
enableHover: true
66+
});
67+
68+
const imageLoaded = ref(new Array(props.images.length).fill(false));
69+
const cardRefs = ref<HTMLElement[]>([]);
70+
71+
const getNoRotationTransform = (transformStr: string): string => {
72+
const hasRotate = /rotate\([\s\S]*?\)/.test(transformStr);
73+
if (hasRotate) {
74+
return transformStr.replace(/rotate\([\s\S]*?\)/, 'rotate(0deg)');
75+
} else if (transformStr === 'none') {
76+
return 'rotate(0deg)';
77+
} else {
78+
return `${transformStr} rotate(0deg)`;
79+
}
80+
};
81+
82+
const getPushedTransform = (baseTransform: string, offsetX: number): string => {
83+
const translateRegex = /translate\(([-0-9.]+)px\)/;
84+
const match = baseTransform.match(translateRegex);
85+
if (match) {
86+
const currentX = parseFloat(match[1]);
87+
const newX = currentX + offsetX;
88+
return baseTransform.replace(translateRegex, `translate(${newX}px)`);
89+
} else {
90+
return baseTransform === 'none' ? `translate(${offsetX}px)` : `${baseTransform} translate(${offsetX}px)`;
91+
}
92+
};
93+
94+
const pushSiblings = (hoveredIdx: number) => {
95+
if (!props.enableHover) return;
96+
97+
props.images.forEach((_, i) => {
98+
gsap.killTweensOf(cardRefs.value[i]);
99+
100+
const baseTransform = props.transformStyles[i] || 'none';
101+
102+
if (i === hoveredIdx) {
103+
const noRotationTransform = getNoRotationTransform(baseTransform);
104+
gsap.to(cardRefs.value[i], {
105+
transform: noRotationTransform,
106+
duration: 0.4,
107+
ease: 'back.out(1.4)',
108+
overwrite: 'auto'
109+
});
110+
} else {
111+
const offsetX = i < hoveredIdx ? -160 : 160;
112+
const pushedTransform = getPushedTransform(baseTransform, offsetX);
113+
const distance = Math.abs(hoveredIdx - i);
114+
const delay = distance * 0.05;
115+
116+
gsap.to(cardRefs.value[i], {
117+
transform: pushedTransform,
118+
duration: 0.4,
119+
ease: 'back.out(1.4)',
120+
delay,
121+
overwrite: 'auto'
122+
});
123+
}
124+
});
125+
};
126+
127+
const resetSiblings = () => {
128+
if (!props.enableHover) return;
129+
130+
props.images.forEach((_, i) => {
131+
gsap.killTweensOf(cardRefs.value[i]);
132+
const baseTransform = props.transformStyles[i] || 'none';
133+
gsap.to(cardRefs.value[i], {
134+
transform: baseTransform,
135+
duration: 0.4,
136+
ease: 'back.out(1.4)',
137+
overwrite: 'auto'
138+
});
139+
});
140+
};
141+
142+
const onImageLoad = (idx: number) => {
143+
imageLoaded.value[idx] = true;
144+
};
145+
146+
const onImageError = (idx: number) => {
147+
imageLoaded.value[idx] = true;
148+
};
149+
150+
const playEntranceAnimation = () => {
151+
gsap.killTweensOf(cardRefs.value);
152+
gsap.set(cardRefs.value, { opacity: 0, scale: 0 });
153+
154+
gsap.fromTo(
155+
cardRefs.value,
156+
{ scale: 0, opacity: 0 },
157+
{
158+
scale: 1,
159+
opacity: 1,
160+
stagger: props.animationStagger,
161+
ease: props.easeType,
162+
delay: props.animationDelay
163+
}
164+
);
165+
};
166+
167+
onMounted(playEntranceAnimation);
168+
watch(() => props.images, async () => {
169+
await nextTick();
170+
gsap.set(cardRefs.value, { opacity: 0, scale: 0 });
171+
playEntranceAnimation();
172+
});
173+
174+
onUnmounted(() => {
175+
gsap.killTweensOf(cardRefs.value);
176+
});
177+
</script>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
2+
<template>
3+
<TabbedLayout>
4+
<template #preview>
5+
<div class="demo-container bounce-cards-demo">
6+
<RefreshButton @refresh="forceRerender" />
7+
8+
<BounceCards
9+
:key="rerenderKey"
10+
class="custom-bounceCards"
11+
:images="images"
12+
:animation-delay="animationDelay"
13+
:animation-stagger="animationStagger"
14+
ease-type="elastic.out(1, 0.5)"
15+
:transform-styles="transformStyles"
16+
:enable-hover="enableHover"
17+
/>
18+
</div>
19+
20+
<Customize>
21+
<PreviewSwitch
22+
title="Enable Hover Effect"
23+
v-model="enableHover"
24+
/>
25+
26+
<PreviewSlider
27+
title="Animation Delay"
28+
v-model="animationDelay"
29+
:min="0.1"
30+
:max="2"
31+
:step="0.1"
32+
/>
33+
34+
<PreviewSlider
35+
title="Animation Stagger"
36+
v-model="animationStagger"
37+
:min="0"
38+
:max="0.3"
39+
:step="0.01"
40+
/>
41+
</Customize>
42+
43+
<PropTable :data="propData" />
44+
45+
<Dependencies :dependency-list="['gsap']" />
46+
</template>
47+
48+
<template #code>
49+
<CodeExample :code-object="bounceCards" />
50+
</template>
51+
52+
<template #cli>
53+
<CliInstallation :command="bounceCards.cli" />
54+
</template>
55+
</TabbedLayout>
56+
</template>
57+
58+
<script setup lang="ts">
59+
import { ref } from 'vue';
60+
import TabbedLayout from '@/components/common/TabbedLayout.vue';
61+
import RefreshButton from '@/components/common/RefreshButton.vue';
62+
import PropTable from '@/components/common/PropTable.vue';
63+
import Dependencies from '@/components/code/Dependencies.vue';
64+
import CliInstallation from '@/components/code/CliInstallation.vue';
65+
import CodeExample from '@/components/code/CodeExample.vue';
66+
import Customize from '@/components/common/Customize.vue';
67+
import PreviewSwitch from '@/components/common/PreviewSwitch.vue';
68+
import PreviewSlider from '@/components/common/PreviewSlider.vue';
69+
import BounceCards from '@/content/Components/BounceCards/BounceCards.vue';
70+
import { bounceCards } from '@/constants/code/Components/bounceCardsCode';
71+
import { useForceRerender } from '@/composables/useForceRerender';
72+
73+
const enableHover = ref(false);
74+
const animationDelay = ref(1);
75+
const animationStagger = ref(0.08);
76+
const { rerenderKey, forceRerender } = useForceRerender();
77+
78+
const images = ref([
79+
'https://picsum.photos/400/400?grayscale',
80+
'https://picsum.photos/500/500?grayscale',
81+
'https://picsum.photos/600/600?grayscale',
82+
'https://picsum.photos/700/700?grayscale',
83+
'https://picsum.photos/300/300?grayscale'
84+
]);
85+
86+
const transformStyles = ref([
87+
'rotate(5deg) translate(-150px)',
88+
'rotate(0deg) translate(-70px)',
89+
'rotate(-5deg)',
90+
'rotate(5deg) translate(70px)',
91+
'rotate(-5deg) translate(150px)'
92+
]);
93+
94+
const propData = [
95+
{
96+
name: 'className',
97+
type: 'string',
98+
default: '-',
99+
description: 'Additional CSS classes for the container.'
100+
},
101+
{
102+
name: 'images',
103+
type: 'string[]',
104+
default: '[]',
105+
description: 'Array of image URLs to display.'
106+
},
107+
{
108+
name: 'containerWidth',
109+
type: 'number',
110+
default: '400',
111+
description: 'Width of the container (px).'
112+
},
113+
{
114+
name: 'containerHeight',
115+
type: 'number',
116+
default: '400',
117+
description: 'Height of the container (px).'
118+
},
119+
{
120+
name: 'animationDelay',
121+
type: 'number',
122+
default: '-',
123+
description: 'Delay (in seconds) before the animation starts.'
124+
},
125+
{
126+
name: 'animationStagger',
127+
type: 'number',
128+
default: '-',
129+
description: 'Time (in seconds) between each card\'s animation.'
130+
},
131+
{
132+
name: 'easeType',
133+
type: 'string',
134+
default: 'elastic.out(1, 0.8)',
135+
description: 'Easing function for the bounce.'
136+
},
137+
{
138+
name: 'transformStyles',
139+
type: 'string[]',
140+
default: 'various rotations/translations',
141+
description: 'Custom transforms for each card position.'
142+
},
143+
{
144+
name: 'enableHover',
145+
type: 'boolean',
146+
default: 'false',
147+
description: 'If true, hovering pushes siblings aside and flattens the hovered card\'s rotation.'
148+
}
149+
];
150+
</script>
151+
152+
<style scoped>
153+
.bounce-cards-demo {
154+
min-height: 400px;
155+
position: relative;
156+
overflow: hidden;
157+
display: flex;
158+
justify-content: center;
159+
align-items: center;
160+
margin-bottom: 1em;
161+
}
162+
</style>

0 commit comments

Comments
 (0)