# Building a Drawer Component

> A comprehensive guide to building a polished, performant drawer component for Vue and Nuxt, inspired by Vaul.

Published 2025-01-05 by Jaber El Ferkh. Tags: vue, nuxt, component, mobile, ux.
Canonical page: https://jaberelferkh.com/topics/building-a-drawer-component

---
Creating a drawer component that feels native and performant requires attention to detail. **<a href="https://vaul.emilkowal.ski/default" target="_blank" rel="noopener noreferrer">Vaul</a>** 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.

::spacer
::

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.

::spacer
::

## Default Drawer

The most common drawer pattern slides up from the bottom of the screen, perfect for mobile-first experiences and action sheets.

::drawer-demo
::

::divider
::

## 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.

::spacer
::

## 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:

```javascript
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.

::spacer
::

## 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:

```javascript
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.

::spacer
::

## 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:

```javascript
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);
	}
};
```

::spacer
::

## Progressive Overlay Fade

As users drag the drawer, the overlay should fade proportionally based on the drawer's position relative to the viewport edge:

```javascript
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.

::spacer
::

## 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:

```vue
<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.

::spacer
::

## Motion and Timing

The animation timing is crucial for achieving that native feel. Using a **cubic-bezier curve inspired by Ionic Framework**:

```css
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.

::spacer
::

## 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:

```javascript
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.

::spacer
::

## 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:

```javascript
// 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**.

::spacer
::

## 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.

::side-drawers-demo
::

**Implementation Details**:

```javascript
// 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**:

```css
.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;
}
```

::spacer
::

## 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](https://sonner.emilkowal.ski/)-like stacking experience.

::nested-drawers-demo
::

### Nested Side Drawers (Left & Right)

Nesting works for horizontal drawers too. The parent scales inward from its open edge, keeping the visual hierarchy clear while the child slides in from the same side.

::nested-side-drawers-demo
::

The implementation is identical to bottom-drawer nesting — just set `position="left"` or `position="right"` on both the parent and child:

```vue
<!-- Nested left drawer -->
<Drawer position="left">
  <template #trigger>
    <Button>Open Left Drawer</Button>
  </template>
  <template #default="{ close }">
    <p>Parent content</p>

    <Drawer position="left">
      <template #trigger>
        <Button>Open Nested Left</Button>
      </template>
      <template #default="{ close: closeNested }">
        <p>Nested content — drag left to see parent scale back.</p>
        <Button @click="closeNested">Close</Button>
      </template>
    </Drawer>
  </template>
</Drawer>

<!-- Nested right drawer -->
<Drawer position="right">
  <template #trigger>
    <Button>Open Right Drawer</Button>
  </template>
  <template #default="{ close }">
    <p>Parent content</p>

    <Drawer position="right">
      <template #trigger>
        <Button>Open Nested Right</Button>
      </template>
      <template #default="{ close: closeNested }">
        <p>Nested content — drag right to see parent scale back.</p>
        <Button @click="closeNested">Close</Button>
      </template>
    </Drawer>
  </template>
</Drawer>
```

### 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.

::spacer
::

**Nesting Context**:

```typescript
// Provide nesting context to child drawers
provide('drawer-nesting', {
    level: currentLevel.value,
    isDragging,
    dragProgress,
    hasOpenChild,
    parentHeight: drawerHeight,
    reportChildDrag: (progress: number) => {
        childDragProgress.value = progress;
    },
});
```

**`reportChildDrag` — Smooth Scale-Back on Drag**:

The key improvement over a basic `hasOpenChild` flag is `reportChildDrag`. As the child is dragged away, it continuously reports its drag progress (0→1) to the parent. The parent interpolates its scale back from `baseScale` toward `1` in real time, so the scale-back is tied directly to the drag gesture rather than snapping when the child closes.

```javascript
// Child reports drag progress to parent during gesture
watch(effectiveDragProgress, (progress) => {
    parentNesting.reportChildDrag(progress);
});

// Parent interpolates scale based on child drag progress
const scale = baseScale + (1 - baseScale) * childDragProgress.value;
```

**`isChildScaleActive` — Smooth Scale-Back on Overlay Click**:

When the child is dismissed via overlay click (no drag gesture), there's no drag progress to interpolate. `isChildScaleActive` stays `true` for 450ms after the child closes, giving the CSS transition time to animate back to `scale(1)` smoothly.

```javascript
watch(hasOpenChild, (open) => {
    if (open) {
        isChildScaleActive.value = true;
    } else {
        // Hold active state for 450ms so the CSS transition can play out
        childScaleTimer = setTimeout(() => {
            isChildScaleActive.value = false;
        }, 450);
    }
});
```

**Horizontal Drawer Scaling**:

Side drawers scale with a position-aware `transformOrigin` so the parent compresses toward its open edge rather than its center, which matches the natural direction the child slides in from.

```javascript
const isHorizontal = props.position === 'left' || props.position === 'right';
const baseScale = isHorizontal ? 0.97 : 0.95;

if (isHorizontal) {
    // Scale toward the open edge, not the center
    styles.transformOrigin = props.position === 'right'
        ? 'center left'
        : 'center right';
}
```

**Overlay Opacity**:

```javascript
// 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 when child opens (95% vertical, 97% horizontal)
- Real-time scale interpolation during child drag via `reportChildDrag`
- Smooth scale-back on overlay-click dismiss via `isChildScaleActive` timer
- Position-aware `transformOrigin` for horizontal drawers
- 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

::spacer
::

## 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.

::non-dismissible-demo
::

### Implementation

Simply set the `dismissable` prop to `false`:

```vue
<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**:

```javascript
const handleOverlayClick = () => {
	if (props.dismissable) {
		// Only close if dismissable
		closeDrawer();
	}
};

const handleEscape = (e: KeyboardEvent) => {
	if (e.key === 'Escape' && props.dismissable && isOpen.value) {
		closeDrawer();
	}
};
```

::spacer
::

## 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 `dismissable` is 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

::spacer
::

## Component API Reference


```typescript
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
}
```

::spacer
::

## Key Takeaways

1. **Performance matters**: Direct transform updates beat CSS variables for drag interactions
2. **Scroll conflicts**: Implement `shouldDrag` logic to handle scrollable content gracefully
3. **Smooth animations**: Always animate to completion rather than instant state changes
4. **Progressive feedback**: Fade overlays and shadows proportionally during drag
5. **Velocity detection**: Make interactions feel responsive by considering flick speed
6. **Multi-position support**: Four positions (top, right, bottom, left) with optimized animations
7. **Nested drawer support**: Automatic parent scaling and z-index management for stacked drawers
8. **Non-dismissible mode**: Critical actions can require explicit user confirmation
9. **Flexible API**: Works with v-model, trigger slot, or programmatic control
10. **Native feel**: Small details like timing curves and animation choreography matter
11. **Framework-native implementation**: Vue's Composition API and provide/inject make this feel natural in Vue/Nuxt projects

::spacer
::

<!-- ## Browser Support

The component uses modern web APIs:

- **Touch Events**: Universal mobile support
- **Pointer Events**: Enhanced desktop interactions
- **CSS Transitions**: Smooth, GPU-accelerated animations
- **Teleport**: Vue 3 feature for portal-style rendering

Works on all modern browsers and mobile devices (iOS 13+, Android 5+).

::spacer
:: -->

## 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.

::spacer
::

**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.

::spacer
::

### Get in Touch

Interested in early access, have feedback, or want to collaborate? I'd love to hear from you! [Contact me](/contact) to discuss the upcoming package, share your use cases, or contribute to making this the best drawer component for the Vue ecosystem.

::spacer
::
