Contact Page Animation System with VCard Export
Contact Page Animation System: From Individual Fields to Unified Export
Internal Development Case Study
The Problem
Leading up to 2025 MongoDB .local, I had a spare NFC chip available that was unlocked and had enough physical space to store a couple files/links and thats where the lightbulb struck.
"What if I am able to share my contact with anyone I end up speaking to throughout the course of the event? š”"
The goal was to create an engaging, saveable contact experience that would allow visitors to easily export contact information while providing visual feedback through smooth animations.
The challenge: design a system that could highlight individual contact fields, morph them into a unified visual representation, and export a properly formatted VCard, all while maintaining the established design system and animation patterns.
The Investigation
Initial requirements analysis revealed several UX considerations:
- Visual Hierarchy: Contact information needed clear organization (name, location, email, phone, social)
- Saveable Format: VCard export functionality for easy contact import
- Animation Flow: Smooth transitions that guide user attention
- Design Consistency: Follow existing FadeIn motion patterns and color system
- Accessibility: Maintain proper focus states and screen reader compatibility
The existing pages followed and used Framer Motion with specific stagger patterns and Radix UI colors that were strictly layered with the core purpose of structure and standard, requiring this new contact page to integrate seamlessly with this baseline.
The Solution: Sequential Animation Architecture
Rather than static contact display, I built a progressive animation system that guides users through a "save contact" flow with visual storytelling at its core.
Core Animation Sequence
Individual Field Highlighting
const fieldSequence = ['name', 'location', 'email', 'phone', 'twitter', 'linkedin'];
for (let i = 0; i < fieldSequence.length; i++) {
await new Promise(resolve => setTimeout(resolve, 300));
setFields(prev => ({
...prev,
[fieldSequence[i]]: true
}));
}Morphed Box Transition
{showMorphedBox && (
<div className="absolute -top-12 inset-x-0 bottom-0 rounded border border-gray-6 animate-fadeInScale flex items-center justify-center">
{saveStatus && (
<div className="text-sm font-medium text-foreground bg-background px-3 py-1 rounded border border-gray-4">
{saveStatus === "saving" ? "Saving..." : "Saved!"}
</div>
)}
</div>
)}State Management System
Multi-State Architecture
const [isAnimating, setIsAnimating] = useState(false);
const [showMorphedBox, setShowMorphedBox] = useState(false);
const [showEasterEgg, setShowEasterEgg] = useState(false);
const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | null>(null);
const [fields, setFields] = useState({
name: false,
location: false,
email: false,
phone: false,
twitter: false,
linkedin: false,
});Hurdle #1: CSS Positioning Conflicts
The Problem: Individual field highlights and the morphed box were creating visual overlaps due to Tailwind's important: true configuration and article CSS applying margin-top: 24px to paragraphs.
Investigation:
/* In styles/main.css */
article {
p:not(:first-child) {
margin-top: 24px; /* Conflicting with absolute positioning */
}
}Solution: Implemented conditional opacity transitions instead of complex positioning logic:
className={`transition-all duration-500 ${
fields.location && !showMorphedBox ? "rounded border border-gray-6 opacity-100" :
showMorphedBox ? "opacity-0" : "opacity-100"
}`}Hurdle #2: Animation Timing Conflicts
The Problem: Individual highlights and the morphed box were appearing simultaneously, creating weird visual stacking effects.
Investigation: The original timing was too aggressive:
// Original - caused overlaps
await new Promise(resolve => setTimeout(resolve, 500));
setShowMorphedBox(true);Solution: Refined timing sequence with proper fade-out coordination:
// Refined timing prevents overlap
await new Promise(resolve => setTimeout(resolve, 300));
setShowMorphedBox(true);
setSaveStatus("saving");Added custom Tailwind animation for smooth morphed box entrance:
// tailwind.config.ts
fadeInScale: {
"0%": { opacity: "0", transform: "scale(0.95)" },
"100%": { opacity: "1", transform: "scale(1)" },
}Hurdle #3: VCard Format Compatibility
The Problem: Generated VCard files weren't importing correctly into contacts applications.
Investigation: Initial VCard had formatting issues:
// Problematic format
let vcard = "BEGIN:VCARD\nVERSION:3.0\n";
if (fields.phone) vcard += "TEL:+1 (234) 567-890\n"; // Wrong phone numberSolution: Implemented proper RFC 2426 VCard standards:
const generateVCard = () => {
let vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\n";
// Always include name since it's the primary contact
vcard += "FN:Vansh Bataviya\r\n";
vcard += "N:Bataviya;Vansh;;;\r\n";
// Proper phone format without spaces/parentheses
vcard += "TEL:+13157422204\r\n";
vcard += "END:VCARD\r\n";
return vcard;
};Key fixes:
- Changed line endings from
\nto\r\n - Added structured name format (
N:) alongside formatted name (FN:) - Removed spaces and parentheses from phone number
- Ensured all contact information matched displayed values
Hurdle #4: Button State Management
The Problem: The "Save Contact" button remained visible while the save status was shown on the morphed box, creating redundant UI elements.
Solution: Implemented coordinated fade-out of button when morphed box appears:
className={`rounded px-3 py-2 text-xs transition-all duration-500 ${
showMorphedBox ? "opacity-0" : "opacity-100"
}`}This creates a clean handoff where the button's functionality is visually transferred to the morphed box status display.
The Final Implementation
Animation Flow
- User clicks "Save Contact" ā Button becomes "Saving Contact..."
- Sequential highlighting ā Individual fields highlight top to bottom (300ms intervals)
- Morphed box appears ā Large box eases in while individuals fade out
- Status progression ā "Saving..." changes to "Saved!"
- Download trigger ā VCard automatically downloads
- Easter egg ā "(not savable to contacts)" appears next to Resume
- Reset ā All states return to initial values
Performance Optimizations
- Used CSS transforms and opacity for animations (GPU-accelerated)
- Avoided layout-shifting properties during transitions~`
- Implemented proper cleanup with
URL.revokeObjectURL() - Respecting
prefers-reduced-motionthrough Tailwind's built-in support
Lessons Learned
State Coordination: Multi-state animations require timing orchestration. Using async/await with setTimeout provides with more predictable sequencing whilst maintaining readability(for future self).
VCard Standards: Contact export formats require some awareness of RFC specifications existing. Small formatting details along the development of this contacts export(line endings, field structure) significantly impacted compatibility in initial iterations with export.
Visual Continuity: Smooth state transitions prevent jarring UI changes. Coordinating element visibility ensures users never see conflicting or overlapping states.
CSS Specificity: Given another attempt to build more custom one off interactions, Tailwind's important: true configuration came with conflicts as anticipated.
The contact animation system successfully transforms a static information page into an engaging, functional interface that guides users through contact saving while maintaining the portfolio's established design language and performance standards.