MagicDrawer
MagicDrawer is a flexible, touch enabled, unstyled drawer component. Useful for things like shopping carts, menus, as a modal replacement on mobile devices and the like.
<template>
<m-button @click="drawerApi.open">Open Drawer</m-button>
<magic-drawer id="magic-drawer-demo" :options="{ focusTrap: false }">
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const drawerApi = useMagicDrawer('magic-drawer-demo')
</script>Anatomy
MagicDrawer can be used as a single self-contained component or composed from its individual parts for full control.
Simple
<template>
<magic-drawer id="your-drawer-id">
<!-- your content -->
</magic-drawer>
</template>
<script setup>
const { open } = useMagicDrawer('your-drawer-id')
</script>
Composed
<template>
<magic-drawer-provider id="your-drawer-id">
<magic-drawer-trigger as-child>
<button>Open</button>
</magic-drawer-trigger>
<magic-drawer-teleport>
<magic-drawer-backdrop />
<magic-drawer-content>
<!-- your content -->
</magic-drawer-content>
</magic-drawer-teleport>
</magic-drawer-provider>
</template>
Installation
CLI
Add @maas/vue-equipment to your dependencies.
pnpm add @maas/vue-equipment
Vue
If you are using Vue, import and add MagicDrawerPlugin to your app.
import { createApp } from 'vue'
import { MagicDrawerPlugin } from '@maas/vue-equipment/plugins/MagicDrawer'
const app = createApp({})
app.use(MagicDrawerPlugin)
Nuxt
The drawer is available as a Nuxt module. In your Nuxt config file add @maas/vue-equipment/nuxt to your modules and add MagicDrawer to the plugins in your configuration.
export default defineNuxtConfig({
modules: ['@maas/vue-equipment/nuxt'],
vueEquipment: {
plugins: ['MagicDrawer'],
},
})
Direct Import
If you prefer a more granular approach, components can be directly imported.
<script setup>
import {
MagicDrawerProvider,
MagicDrawerTeleport,
MagicDrawerBackdrop,
MagicDrawerContent,
MagicDrawerTrigger,
} from '@maas/vue-equipment/plugins/MagicDrawer'
</script>
Composable
In order to interact with the drawer from anywhere within your app, we provide a useMagicDrawer composable. Import it directly when needed.
import { onMounted } from 'vue'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const { open } = useMagicDrawer('your-drawer-id')
onMounted(() => {
open()
})
Peer Dependencies
MagicDrawer relies on the following optional peer dependencies in addition to the core peer dependencies. Package managers don’t install optional peer dependencies automatically, so add them to your project.
Installation
pnpm add @vueuse/integrations focus-trap wheel-gestures
API Reference
MagicDrawerProvider
The MagicDrawerProvider wraps the drawer and configures all child components according to the provided options.
Props
Options
To customize the drawer, override the necessary options. Any custom options will be merged with the default options.
MagicDrawerTeleport
Teleports all child components to a target in the DOM. Uses v-if to mount and unmount its contents when the drawer opens and closes — keeping the DOM clean when the drawer is inactive. Omit this component if you want the drawer to remain mounted at all times.
Props
MagicDrawerBackdrop
Renders a full-viewport overlay behind the drawer panel. Closes the drawer when clicked.
CSS Variables
MagicDrawerContent
Renders the drawer panel with drag, snap, and scroll behavior.
CSS Variables
MagicDrawerTrigger
Opens or closes the drawer on click.
Props
Slot Props
MagicDrawer
A self-contained component that composes all primitives internally. Use this for simple cases where you don’t need to customise the markup.
Props
Slots
useMagicDrawer
The composable returns the drawer’s reactive state and a set of functions to control it programmatically.
Events
The drawer emits the following events through MagicEmitter. Listen to them with useMagicEmitter.
Errors
Caveats
The drawer handles situations where dragging and scrolling might interfere with each other on touch devices. In order for the drawer to differentiate when the user scrolls and when the user drags, any scrollable containers within the drawer need to have their overflow value explicitly set to auto or scroll.
On iOS, a touch that starts near a vertical viewport edge arms the browser’s back and forward navigation swipe — in Safari as well as in installed web apps. Drawers with a left or right position drag along the same axis, so they cancel touchstart within 44px of either edge, which is the one signal WebKit honors, and the drag stays with the drawer. Canceling also swallows the tap’s synthesized click; the drawer dispatches a replacement when the touch ends within 12px of where it started, so taps inside the strip keep working. A touch on an SVG icon counts as well, and its replacement click goes to the HTML element that holds the icon. Scrolling cannot start inside the strip — a touch that begins there belongs to the drag. Set preventEdgeNavigation to false to opt out.
Starting with iOS 26, Safari renders page content beneath its translucent UI chrome, while position: fixed elements can stop short of the bottom controls. A fixed backdrop may leave a strip of undimmed content visible behind the chrome. To cover everything on screen, switch the backdrop to position: absolute and give it a positioned ancestor:
body {
position: relative;
margin: 0;
}
@supports (-webkit-touch-callout: none) {
:root {
--magic-drawer-backdrop-position: absolute;
--magic-drawer-backdrop-height: max(100%, 100dvh);
}
}
The @supports query limits the override to iOS, where every browser runs WebKit. The height override keeps the backdrop covering the entire document on scrolled pages, while never shrinking below the viewport on short ones. Leave --magic-drawer-position untouched — the content needs position: fixed to anchor the drawer to the viewport.
The pattern relies on body spanning the full document and does not cover pages that scroll horizontally. If you teleport the drawer somewhere other than body, the new target needs to be the positioned ancestor instead.
Examples
Vertical
<template>
<m-button @click="drawerApi.open">Open Drawer</m-button>
<magic-drawer id="magic-drawer-vertical-demo" :options="{ focusTrap: false }">
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const drawerApi = useMagicDrawer('magic-drawer-vertical-demo')
</script>Horizontal
<template>
<m-button @click="drawerApi.open">Open Drawer</m-button>
<magic-drawer
id="magic-drawer-horizontal-demo"
:options="{ focusTrap: false, position: 'right' }"
>
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const drawerApi = useMagicDrawer('magic-drawer-horizontal-demo')
</script>
<style>
[data-id='magic-drawer-horizontal-demo'] {
--magic-drawer-height: 100svh;
--magic-drawer-width: 20rem;
}
</style>Snap Points
<template>
<m-button @click="drawerApi.open">Open Drawer</m-button>
<magic-drawer
id="magic-drawer-snap-points-demo"
:options="{
focusTrap: false,
snapPoints: snapPoints,
initial: { snapPoint: snapPoints[0] },
}"
>
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
import type { MagicDrawerSnapPoint } from '@maas/vue-equipment/plugins/MagicDrawer'
const snapPoints: Array<MagicDrawerSnapPoint> = ['320px', 0.75, 1]
const drawerApi = useMagicDrawer('magic-drawer-snap-points-demo')
</script>
<style>
[data-id='magic-drawer-snap-points-demo'] {
--magic-drawer-height: 100svh;
}
</style>Mousewheel
<template>
<m-button @click="drawerApi.open">Open Drawer</m-button>
<magic-drawer
id="magic-drawer-mousewheel-demo"
:options="{ focusTrap: false, enableMousewheel: true }"
>
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const drawerApi = useMagicDrawer('magic-drawer-mousewheel-demo')
</script>Step Snapping
With snap.mode set to 'step', the drawer advances to the adjacent snap point on each drag instead of jumping to the closest one, moving through the configured points one at a time.
<template>
<m-button @click="drawerApi.open">Open Drawer</m-button>
<magic-drawer
id="magic-drawer-snap-mode-demo"
:options="{
focusTrap: false,
snapPoints: snapPoints,
snap: { mode: 'step' },
initial: { snapPoint: snapPoints[0] },
}"
>
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const snapPoints = [0.25, 0.5, 0.75, 1]
const drawerApi = useMagicDrawer('magic-drawer-snap-mode-demo')
</script>
<style>
[data-id='magic-drawer-snap-mode-demo'] {
--magic-drawer-height: 100svh;
}
</style>Ranges
Pass { min, max } in place of a snap point to let the drawer rest anywhere between two points. A release inside the range does not snap, while a release outside snaps to the closest point. Momentum detection is preserved. Add momentum: false to disable momentum based snapping inside the range.
const options: MagicDrawerOptions = {
snapPoints: [{ min: 0.25, max: 0.75 }, 1],
}
Events and activeSnapPoint report the resting value to four decimals, like 0.4375, if both ends of the range are decimals, and in whole pixels, like '420px', otherwise.
<template>
<m-button @click="open">Open Drawer</m-button>
<magic-drawer
id="magic-drawer-range-demo"
:options="{
focusTrap: false,
snapPoints: [{ min: 0.25, max: 0.75 }, 1],
initial: { snapPoint: 0.5 },
}"
>
<div class="bg-surface-base absolute inset-0" />
<div class="relative flex w-full justify-center self-start">
<div
class="bg-surface-high mt-8 rounded-[0.5rem] px-4 py-2 font-mono text-sm"
>
<span class="text-surface-muted">activeSnapPoint</span>
<span class="text-surface ml-3">{{ activeSnapPoint ?? '–' }}</span>
</div>
</div>
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const { open, activeSnapPoint } = useMagicDrawer('magic-drawer-range-demo')
</script>
<style>
[data-id='magic-drawer-range-demo'] {
--magic-drawer-height: 100svh;
}
</style>With a range from 0 as its only snap point, the drawer rests anywhere up to the range’s end, and closes once dragged to within threshold.distance of the bottom.
<template>
<m-button @click="open">Open Drawer</m-button>
<magic-drawer
id="magic-drawer-range-close-demo"
:options="{
focusTrap: false,
snapPoints: [{ min: 0, max: 0.5 }],
initial: { snapPoint: 0.5 },
}"
>
<div class="bg-surface-base absolute inset-0" />
<div class="relative flex w-full justify-center self-start">
<div
class="bg-surface-high mt-8 rounded-[0.5rem] px-4 py-2 font-mono text-sm"
>
<span class="text-surface-muted">activeSnapPoint</span>
<span class="text-surface ml-3">{{ activeSnapPoint ?? '–' }}</span>
</div>
</div>
</magic-drawer>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
const { open, activeSnapPoint } = useMagicDrawer(
'magic-drawer-range-close-demo'
)
</script>
<style>
[data-id='magic-drawer-range-close-demo'] {
--magic-drawer-height: 100svh;
}
</style>Composed
<template>
<magic-drawer-provider
id="magic-drawer-composed-demo"
:options="{ focusTrap: false }"
>
<magic-drawer-trigger as-child>
<m-button>Open Drawer</m-button>
</magic-drawer-trigger>
<magic-drawer-teleport>
<magic-drawer-backdrop />
<magic-drawer-content>
<div class="bg-surface-base absolute inset-0" />
</magic-drawer-content>
</magic-drawer-teleport>
</magic-drawer-provider>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import {
MagicDrawerBackdrop,
MagicDrawerContent,
MagicDrawerProvider,
MagicDrawerTeleport,
MagicDrawerTrigger,
} from '@maas/vue-equipment/plugins/MagicDrawer'
</script>Will Snap To
The willSnapTo event fires during a drag whenever the snap point the drawer will animate to on release changes. It only fires when the target changes, not on every move.
<template>
<div class="flex flex-col items-center gap-4 select-none">
<m-button @click="drawerApi.open">Open Drawer</m-button>
<div
class="bg-surface-low text-surface w-120 max-w-full rounded-[0.25rem] p-4 font-mono text-sm"
>
<div class="text-surface-muted mb-3 text-xs tracking-widest uppercase">
snap points
</div>
<div class="mb-4 flex justify-between">
<div
v-for="point in snapPoints"
:key="String(point)"
class="flex flex-col items-center gap-2"
>
<div class="h-2 w-2 rounded-full" :class="dotClass(point)" />
<span class="text-surface-muted text-xs">{{ point }}</span>
</div>
</div>
<div class="text-surface-muted mb-2 text-xs tracking-widest uppercase">
willSnapTo events
</div>
<div class="flex flex-col gap-1.5">
<template v-if="log.length">
<div
v-for="(entry, i) in log"
:key="i"
:class="i === 0 ? 'text-surface' : 'text-surface-muted'"
>
{{ entry.snapPoint }}
</div>
</template>
<span v-else class="text-surface-muted">—</span>
</div>
</div>
<magic-drawer
id="magic-drawer-will-snap-to-demo"
:options="{
focusTrap: false,
snapPoints,
threshold: { distance: 32 },
initial: { snapPoint: snapPoints[0] },
}"
>
<div class="bg-surface-base absolute inset-0" />
</magic-drawer>
</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, ref } from 'vue'
import { MButton } from 'mirror-compat'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'
import { useMagicEmitter } from '@maas/vue-equipment/plugins/MagicEmitter'
import type {
MagicDrawerSnapPoint,
MagicDrawerWillSnapToPayload,
} from '@maas/vue-equipment/plugins/MagicDrawer'
const id = 'magic-drawer-will-snap-to-demo'
const snapPoints: MagicDrawerSnapPoint[] = [0.25, 0.5, 0.75, 1]
const drawerApi = useMagicDrawer(id)
const emitter = useMagicEmitter()
// The pending snap target during drag — null when not dragging
const willSnapTo = ref<MagicDrawerSnapPoint | null>(null)
// The snap point the drawer has settled on
const settledSnapPoint = ref<MagicDrawerSnapPoint>(snapPoints[0]!)
function dotClass(point: MagicDrawerSnapPoint) {
if (willSnapTo.value === point) {
return 'bg-warning-solid'
}
if (settledSnapPoint.value === point) {
return 'bg-success-solid'
}
return 'bg-surface-base'
}
const log = ref<MagicDrawerWillSnapToPayload[]>([])
function onWillSnapTo(payload: MagicDrawerWillSnapToPayload) {
if (payload.id !== id) {
return
}
willSnapTo.value = payload.snapPoint
log.value.unshift(payload)
if (log.value.length > 5) {
log.value.length = 5
}
}
function isDrawerSnapPoint(v: unknown): v is MagicDrawerSnapPoint {
return typeof v === 'number' || typeof v === 'string'
}
function onAfterSnap(payload: { id: string; snapPoint: unknown }) {
if (payload.id !== id) {
return
}
if (isDrawerSnapPoint(payload.snapPoint)) {
settledSnapPoint.value = payload.snapPoint
}
willSnapTo.value = null
}
emitter.on('willSnapTo', onWillSnapTo)
emitter.on('afterSnap', onAfterSnap)
onBeforeUnmount(() => {
emitter.off('willSnapTo', onWillSnapTo)
emitter.off('afterSnap', onAfterSnap)
})
</script>
<style>
[data-id='magic-drawer-will-snap-to-demo'] {
--magic-drawer-height: 100svh;
}
</style>