Browse Source

Add Confetti Animation for Newly Added Tabs in AreaTabs Component

- Integrated the 'canvas-confetti' library to enhance user experience by triggering confetti animations when new tabs are added.
- Implemented logic to track newly added areas and animate their appearance with a highlight effect.
- Updated the AreaTabs.vue component to include transition animations for tab entries and exits, improving visual feedback during navigation.
- Enhanced styling for newly added tabs to draw attention and improve user engagement.
main
Bastian Masanek 1 month ago
parent
commit
e7278d83e8
  1. 198
      app/components/navigation/AreaTabs.vue
  2. 1
      package.json
  3. 8
      pnpm-lock.yaml

198
app/components/navigation/AreaTabs.vue

@ -2,6 +2,7 @@
import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next' import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import confetti from 'canvas-confetti'
type RoleCode = 'private' | 'educator' | 'company' type RoleCode = 'private' | 'educator' | 'company'
@ -104,11 +105,77 @@ const currentArea = computed(() => {
return matchedArea?.id || '' return matchedArea?.id || ''
}) })
// Track previous area IDs for animation detection
const previousAreaIds = ref<string[]>([])
const newlyAddedAreaId = ref<string | null>(null)
const tabRefs = ref<Record<string, HTMLElement>>({})
// Watch for changes in visible areas to trigger animations
watch(visibleAreas, (newAreas, oldAreas) => {
const newAreaIds = newAreas.map(a => a.id)
const oldAreaIds = oldAreas?.map(a => a.id) || []
// Find newly added areas
const addedIds = newAreaIds.filter(id => !oldAreaIds.includes(id))
if (addedIds.length > 0) {
// Mark as newly added for highlight animation
newlyAddedAreaId.value = addedIds[0]
// Trigger confetti after a small delay (so element is rendered)
setTimeout(() => {
const areaId = addedIds[0]
const element = tabRefs.value[areaId]
if (element) {
triggerConfetti(element)
}
}, 300)
// Clear highlight after animation
setTimeout(() => {
newlyAddedAreaId.value = null
}, 2000)
}
previousAreaIds.value = newAreaIds
}, { deep: true })
// Initialize previous area IDs
onMounted(() => {
previousAreaIds.value = visibleAreas.value.map(a => a.id)
})
// Confetti effect from element position
function triggerConfetti(element: HTMLElement) {
const rect = element.getBoundingClientRect()
const x = (rect.left + rect.width / 2) / window.innerWidth
const y = (rect.top + rect.height / 2) / window.innerHeight
// Confetti burst from tab position
confetti({
particleCount: 25,
spread: 50,
origin: { x, y },
colors: ['#E91E85', '#FF6B9D', '#C77DFF', '#9D4EDD'],
ticks: 200,
gravity: 1.2,
scalar: 0.8,
})
}
function navigateToArea(area: ProductArea) { function navigateToArea(area: ProductArea) {
if (area.enabled) { if (area.enabled) {
navigateTo(area.route) navigateTo(area.route)
} }
} }
function setTabRef(areaId: string, el: any) {
if (el) {
// Extract the actual DOM element from Vue component instance
const domElement = el.$el || el
tabRefs.value[areaId] = domElement
}
}
</script> </script>
<template> <template>
@ -116,46 +183,53 @@ function navigateToArea(area: ProductArea) {
<!-- Desktop: Tabs --> <!-- Desktop: Tabs -->
<Tabs :model-value="currentArea" class="hidden md:block"> <Tabs :model-value="currentArea" class="hidden md:block">
<TabsList class="h-auto p-2 bg-white/5"> <TabsList class="h-auto p-2 bg-white/5">
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :value="area.id" <TransitionGroup name="tab" tag="div" class="flex items-center gap-1">
:disabled="!area.enabled" :class="[ <TabsTrigger v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md', :value="area.id" :disabled="!area.enabled" :class="[
!area.enabled && 'opacity-50 cursor-not-allowed', 'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md transition-all duration-300',
]" @click="navigateToArea(area)"> !area.enabled && 'opacity-50 cursor-not-allowed',
<component :is="area.icon" class="h-4 w-4" /> newlyAddedAreaId === area.id && 'tab-highlight',
<span>{{ area.label }}</span> ]" @click="navigateToArea(area)">
<Badge v-if="area.badge" :class="[ <component :is="area.icon" class="h-4 w-4" />
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]', <span>{{ area.label }}</span>
currentArea === area.id <Badge v-if="area.badge" :class="[
? 'bg-white/90 text-purple-950 border-white/50' 'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30' currentArea === area.id
]"> ? 'bg-white/90 text-purple-950 border-white/50'
{{ area.badge }} : 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
</Badge> ]">
</TabsTrigger> {{ area.badge }}
</Badge>
</TabsTrigger>
</TransitionGroup>
</TabsList> </TabsList>
</Tabs> </Tabs>
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) --> <!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
<div class="md:hidden overflow-x-auto scrollbar-hide"> <div class="md:hidden overflow-x-auto scrollbar-hide">
<div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max"> <div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max">
<button v-for="area in visibleAreas" :key="area.id" :disabled="!area.enabled" :class="[ <TransitionGroup name="tab" tag="div" class="inline-flex items-center gap-2">
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0', <button v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
currentArea === area.id :disabled="!area.enabled" :class="[
? 'bg-accent text-white shadow-md' 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0 duration-300',
: 'text-white/70 hover:text-white', currentArea === area.id
!area.enabled && 'opacity-50 cursor-not-allowed', ? 'bg-accent text-white shadow-md'
]" @click="navigateToArea(area)"> : 'text-white/70 hover:text-white',
<component :is="area.icon" class="h-4 w-4" /> !area.enabled && 'opacity-50 cursor-not-allowed',
<span>{{ area.label }}</span> newlyAddedAreaId === area.id && 'tab-highlight',
<Badge v-if="area.badge" :class="[ ]" @click="navigateToArea(area)">
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]', <component :is="area.icon" class="h-4 w-4" />
currentArea === area.id <span>{{ area.label }}</span>
? 'bg-white/90 text-purple-950 border-white/50' <Badge v-if="area.badge" :class="[
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30' 'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
]"> currentArea === area.id
{{ area.badge }} ? 'bg-white/90 text-purple-950 border-white/50'
</Badge> : 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
</button> ]">
{{ area.badge }}
</Badge>
</button>
</TransitionGroup>
</div> </div>
</div> </div>
</div> </div>
@ -171,4 +245,60 @@ function navigateToArea(area: ProductArea) {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
} }
/* Vue TransitionGroup animations for tabs */
.tab-enter-active {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tab-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
}
.tab-enter-from {
opacity: 0;
transform: translateX(-20px) scale(0.9);
}
.tab-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.95);
}
.tab-move {
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Highlight animation for newly added tabs */
@keyframes highlight-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
}
50% {
box-shadow: 0 0 20px 4px rgba(233, 30, 133, 0.6),
0 0 40px 8px rgba(201, 125, 255, 0.4);
}
}
@keyframes highlight-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.tab-highlight {
animation: highlight-glow 1s ease-in-out 2,
highlight-pulse 0.5s ease-in-out 2;
background: linear-gradient(135deg, rgba(233, 30, 133, 0.2), rgba(201, 125, 255, 0.2)) !important;
}
</style> </style>

1
package.json

@ -29,6 +29,7 @@
"@nuxtjs/i18n": "^10.1.2", "@nuxtjs/i18n": "^10.1.2",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",

8
pnpm-lock.yaml

@ -17,6 +17,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3)) version: 14.0.0(vue@3.5.22(typescript@5.9.3))
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -2194,6 +2197,9 @@ packages:
caniuse-lite@1.0.30001751: caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
case-anything@3.1.2: case-anything@3.1.2:
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==} resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -7482,6 +7488,8 @@ snapshots:
caniuse-lite@1.0.30001751: {} caniuse-lite@1.0.30001751: {}
canvas-confetti@1.9.4: {}
case-anything@3.1.2: {} case-anything@3.1.2: {}
chalk@4.1.2: chalk@4.1.2:

Loading…
Cancel
Save