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. 140
      app/components/navigation/AreaTabs.vue
  2. 1
      package.json
  3. 8
      pnpm-lock.yaml

140
app/components/navigation/AreaTabs.vue

@ -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,10 +183,12 @@ 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',
<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>
@ -132,18 +201,22 @@ function navigateToArea(area: ProductArea) {
{{ 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',
<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>
@ -156,6 +229,7 @@ function navigateToArea(area: ProductArea) {
{{ 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>

1
package.json

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

8
pnpm-lock.yaml

@ -17,6 +17,9 @@ importers:
'@vueuse/core':
specifier: ^14.0.0
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:
specifier: ^0.7.1
version: 0.7.1
@ -2194,6 +2197,9 @@ packages:
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
case-anything@3.1.2:
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
engines: {node: '>=18'}
@ -7482,6 +7488,8 @@ snapshots:
caniuse-lite@1.0.30001751: {}
canvas-confetti@1.9.4: {}
case-anything@3.1.2: {}
chalk@4.1.2:

Loading…
Cancel
Save