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.
This commit is contained in:
Bastian Masanek
2025-11-05 01:46:35 +01:00
parent 3dc60cdf6a
commit e7278d83e8
3 changed files with 173 additions and 34 deletions

View File

@@ -2,6 +2,7 @@
import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import confetti from 'canvas-confetti'
type RoleCode = 'private' | 'educator' | 'company'
@@ -104,11 +105,77 @@ const currentArea = computed(() => {
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) {
if (area.enabled) {
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>
<template>
@@ -116,46 +183,53 @@ function navigateToArea(area: ProductArea) {
<!-- Desktop: Tabs -->
<Tabs :model-value="currentArea" class="hidden md:block">
<TabsList class="h-auto p-2 bg-white/5">
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :value="area.id"
:disabled="!area.enabled" :class="[
'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md',
!area.enabled && 'opacity-50 cursor-not-allowed',
]" @click="navigateToArea(area)">
<component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span>
<Badge v-if="area.badge" :class="[
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
currentArea === area.id
? 'bg-white/90 text-purple-950 border-white/50'
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
]">
{{ area.badge }}
</Badge>
</TabsTrigger>
<TransitionGroup name="tab" tag="div" class="flex items-center gap-1">
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
:value="area.id" :disabled="!area.enabled" :class="[
'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',
!area.enabled && 'opacity-50 cursor-not-allowed',
newlyAddedAreaId === area.id && 'tab-highlight',
]" @click="navigateToArea(area)">
<component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span>
<Badge v-if="area.badge" :class="[
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
currentArea === area.id
? 'bg-white/90 text-purple-950 border-white/50'
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
]">
{{ area.badge }}
</Badge>
</TabsTrigger>
</TransitionGroup>
</TabsList>
</Tabs>
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
<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">
<button v-for="area in visibleAreas" :key="area.id" :disabled="!area.enabled" :class="[
'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',
currentArea === area.id
? 'bg-accent text-white shadow-md'
: 'text-white/70 hover:text-white',
!area.enabled && 'opacity-50 cursor-not-allowed',
]" @click="navigateToArea(area)">
<component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span>
<Badge v-if="area.badge" :class="[
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
currentArea === area.id
? 'bg-white/90 text-purple-950 border-white/50'
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
]">
{{ area.badge }}
</Badge>
</button>
<TransitionGroup name="tab" tag="div" class="inline-flex items-center gap-2">
<button v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
:disabled="!area.enabled" :class="[
'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',
currentArea === area.id
? 'bg-accent text-white shadow-md'
: 'text-white/70 hover:text-white',
!area.enabled && 'opacity-50 cursor-not-allowed',
newlyAddedAreaId === area.id && 'tab-highlight',
]" @click="navigateToArea(area)">
<component :is="area.icon" class="h-4 w-4" />
<span>{{ area.label }}</span>
<Badge v-if="area.badge" :class="[
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
currentArea === area.id
? 'bg-white/90 text-purple-950 border-white/50'
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
]">
{{ area.badge }}
</Badge>
</button>
</TransitionGroup>
</div>
</div>
</div>
@@ -171,4 +245,60 @@ function navigateToArea(area: ProductArea) {
-ms-overflow-style: 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>