Building a Drawer Component
Creating a drawer component that feels native and performant requires attention to detail. Vaul by Emil Kowalski provides an excellent React implementation inspired by Apple's iOS sheets, setting the standard for drawer experiences. While originally designed for mobile, providing this same level of tactility for desktop users via mouse interactions is what makes a component feel truly premium.
However, the Vue and Nuxt communities deserved their own implementation. This guide documents my journey rebuilding this polished mobile UI drawer component for Vue 3, complete with intuitive drag interactions, velocity-based closing, and smart scroll detection—exploring the key challenges and solutions that make it rival native implementations.
Default Drawer
The most common drawer pattern slides up from the bottom of the screen, perfect for mobile-first experiences and action sheets.
Loading demo...
The Challenge
Building a drawer isn't just about sliding content from the edge of the screen. The real challenge lies in making it feel natural and responsive, handling edge cases that users encounter in real-world usage, and maintaining 60fps performance throughout the interaction.
Vaul solved this beautifully for React developers. The goal was to bring that same level of polish and attention to detail to Vue and Nuxt applications, while leveraging Vue's unique features like the Composition API and provide/inject.
Performance-First Approach
The initial implementation using CSS variables for drag updates caused noticeable frame drops with longer content lists. The solution was updating the transform property directly:
const drawerStyle = computed(() => {
if (!isDragging.value && !isClosing.value) return {};
const offset = dragOffset.value;
return {
transform: `translateY(${offset}px)`,
transition: isClosing.value ? 'transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)' : 'none',
};
});
This approach provides smooth 60fps performance even on mid-range mobile devices with extensive content.
Scroll Conflict Resolution
One of the most critical UX details is preventing the drawer from closing when users are scrolling content inside it. The solution is a shouldDrag function that checks scroll position:
const shouldDrag = (e: TouchEvent | MouseEvent): boolean => {
if (!drawerContent.value) return false;
const drawerBody = drawerContent.value.querySelector('.drawer-body');
if (!drawerBody) return true;
// Only allow dragging when scrolled to the top
return drawerBody.scrollTop === 0; // ... (logic extended for all positions)
};
This prevents accidental drawer dismissal while maintaining the natural swipe-to-close gesture when users are at the top of the content.
Smooth Drag-to-Close Animation
When users drag and release, the drawer should smoothly complete its closing animation rather than disappearing instantly. This is achieved by calculating the final off-screen position and animating to it before actually closing:
const handleTouchEnd = () => {
if (!isDragging.value) return;
const threshold = 100; // pixels
const velocityThreshold = 0.5; // pixels per ms
const shouldClose = Math.abs(dragOffset.value) > threshold || Math.abs(velocity.value) > velocityThreshold;
if (shouldClose) {
isDragging.value = false;
isClosing.value = true;
// Animate to final off-screen position
const totalDistance = drawerContent.value.offsetHeight;
dragOffset.value = totalDistance;
// Close after animation completes
setTimeout(() => {
closeDrawer();
isClosing.value = false;
dragOffset.value = 0;
velocity.value = 0;
}, 300);
}
};
Progressive Overlay Fade
As users drag the drawer, the overlay should fade proportionally based on the drawer's position relative to the viewport edge:
const overlayStyle = computed(() => {
if (!isDragging.value && !isClosing.value) return {};
const totalDistance = drawerContent.value.offsetHeight;
const progress = Math.min(Math.abs(dragOffset.value) / totalDistance, 1);
// Linear fade from full opacity to transparent
const opacity = Math.max(0, 0.5 * (1 - progress));
return {
backgroundColor: `rgba(0, 0, 0, ${opacity})`,
transition: isClosing.value ? 'background-color 0.3s ease' : 'none',
};
});
This creates a natural, physics-based feeling that matches iOS sheet behavior.
Component Architecture
While Vaul leverages React's ecosystem, this drawer component is built natively for Vue 3 using the Composition API and Teleport to render at the body level, ensuring proper z-index management:
<template>
<Teleport to="body">
<Transition name="drawer">
<div v-if="modelValue" class="drawer-overlay">
<div
class="drawer-content"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@mousedown="handleMouseDown"
>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
The component features a simple, focused API that covers essential use cases while keeping everything clean and predictable.
Motion and Timing
The animation timing is crucial for achieving that native feel. Using a cubic-bezier curve inspired by Ionic Framework:
transition: transform 0.45s cubic-bezier(0.32, 0.72, 0, 1);
The overlay fades faster (0.25s) than the drawer slides (0.45s), creating a layered animation that guides the user's attention.
Scroll Lock Management
When the drawer opens, we need to prevent background scrolling while avoiding the dreaded page shift. This is handled using a custom useBodyScrollLock composable with reference counting for nested drawer support:
watch(isOpen, (open: boolean) => {
if (import.meta.client) {
if (open) {
if (currentLevel.value === 1) {
// Root drawer: use composable for scrollbar compensation
lockScroll();
} else {
// Nested drawer: just hide overflow
document.body.style.overflow = 'hidden';
}
} else {
if (currentLevel.value === 1 && !hasOpenChild.value) {
// Root drawer closing: delay matches animation (450ms)
unlockScroll(450);
}
}
}
});
This ensures no visual jump when drawers open or close, and properly handles nested drawer scenarios.
Velocity-Based Closing
Users expect the drawer to close when they flick it away quickly, even if they haven't dragged it far. Velocity detection makes this possible:
// Calculate velocity during drag
velocity.value = (currentY - lastY.value) / (deltaTime || 1);
// Check both distance and velocity when releasing
const shouldClose = Math.abs(dragOffset.value) > 100 || Math.abs(velocity.value) > 0.5;
This creates an interaction that feels responsive and intelligent.
Multi-Position Support
The component supports four positions (top, right, bottom, left) with appropriate direction constraints and optimized animations for each orientation.
Side Drawers (Left & Right)
Side drawers are perfect for navigation menus, filters, and settings panels. They slide in from the left or right edge with a fixed maximum width.
Loading demo...
Implementation Details:
// Direction constraints for each position
if (props.position === 'bottom' && delta < 0) delta = 0; // Bottom: only drag down
if (props.position === 'top' && delta > 0) delta = 0; // Top: only drag up
if (props.position === 'left' && delta > 0) delta = 0; // Left: only drag left
if (props.position === 'right' && delta < 0) delta = 0; // Right: only drag right
Styling for Side Drawers:
.drawer-content--left {
width: 400px;
max-width: 90vw;
height: 100vh;
border-radius: 0 16px 16px 0;
}
.drawer-content--right {
width: 400px;
max-width: 90vw;
height: 100vh;
border-radius: 16px 0 0 16px;
}
Nested Drawers
One of the most powerful features is support for nested drawers with automatic parent scaling and stacking effects. Just like in Vaul, this creates a beautiful Sonner-like stacking experience.
Loading demo...
Nesting Implementation
This Vue implementation leverages Vue's native provide/inject API to track drawer nesting levels and coordinate animations between parent and child drawers.
Nesting Context:
// Provide nesting context to child drawers
provide(DRAWER_NESTING_KEY, {
level: currentLevel.value,
isDragging,
dragProgress,
hasOpenChild,
parentHeight: drawerHeight,
});
Parent Scaling Logic:
// Apply scaling when a child drawer is open or being dragged
if (hasOpenChild.value) {
const scale = 0.99; // Scale down to 99% when child is open
const borderRadius = 20; // Increased border radius when scaled
// If also being dragged, apply additional scaling
if (isDragging.value && dragProgress.value > 0) {
const dragScale = 1 - dragProgress.value * 0.08; // Additional 8% scale down
const finalScale = scale * dragScale;
styles.transform = `${currentTransform} scale(${finalScale})`;
}
}
Overlay Opacity:
// Nested drawers have lighter overlay (30% vs 50%)
const baseOpacity = currentLevel.value > 1 ? 0.3 : 0.5;
const opacity = Math.max(0, baseOpacity * (1 - progress));
Key Features:
- Automatic parent scaling (99%) when child opens
- Progressive border radius increase
- Independent drag gestures for each drawer
- Lighter overlay for nested drawers (30% vs 50%)
- Proper z-index stacking based on nesting level
- Coordinated scroll lock management
Non-Dismissible Drawers
For critical actions that require user confirmation, you can create non-dismissible drawers that can only be closed via explicit button clicks.
Loading demo...
Implementation
Simply set the dismissable prop to false:
<Drawer v-model="isOpen" :dismissable="false">
<h2>Critical Action</h2>
<p>This drawer can only be closed using the button below.</p>
<Button @click="isOpen = false">Close</Button>
</Drawer>
What Gets Disabled:
const handleOverlayClick = () => {
if (props.dismissable) {
// Only close if dismissable
closeDrawer();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.dismissable && isOpen.value) {
closeDrawer();
}
};
Accessibility Considerations
The drawer component includes several accessibility features to ensure it works well for all users:
- Keyboard support: Escape key closes the drawer when
dismissableis true - Touch and mouse support: Works seamlessly on both mobile and desktop devices
- Focus management: Content is accessible and navigable via keyboard
- Screen reader friendly: Close button includes
aria-label="Close drawer" - Non-dismissible mode: Critical actions can prevent accidental dismissal
Component API Reference
interface Props {
modelValue?: boolean; // v-model support (optional)
position?: 'left' | 'right' | 'top' | 'bottom'; // default: 'bottom'
dismissable?: boolean; // default: true
disableScrollLock?: boolean; // default: false
}
// Slots
slots: {
trigger(props: { open: () => void }): any; // Optional trigger element
default(props: { close: () => void }): any; // Drawer content
}
Key Takeaways
- Performance matters: Direct transform updates beat CSS variables for drag interactions
- Scroll conflicts: Implement
shouldDraglogic to handle scrollable content gracefully - Smooth animations: Always animate to completion rather than instant state changes
- Progressive feedback: Fade overlays and shadows proportionally during drag
- Velocity detection: Make interactions feel responsive by considering flick speed
- Multi-position support: Four positions (top, right, bottom, left) with optimized animations
- Nested drawer support: Automatic parent scaling and z-index management for stacked drawers
- Non-dismissible mode: Critical actions can require explicit user confirmation
- Flexible API: Works with v-model, trigger slot, or programmatic control
- Native feel: Small details like timing curves and animation choreography matter
- Framework-native implementation: Vue's Composition API and provide/inject make this feel natural in Vue/Nuxt projects
What's Next?
This implementation demonstrates the core functionality of a production-ready drawer component for Vue and Nuxt applications. While Vaul has set an excellent standard for React, the Vue ecosystem now has its own native solution.
Stay tuned for the official package release! I'm working on publishing a comprehensive package that will make it even easier to integrate this drawer component into your Vue and Nuxt projects with just a single install command.
Get in Touch
Interested in early access, have feedback, or want to collaborate? I'd love to hear from you! Contact me to discuss the upcoming package, share your use cases, or contribute to making this the best drawer component for the Vue ecosystem.