Skip to content
Sponsored by

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 '@maas/mirror/vue'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'

const drawerApi = useMagicDrawer('magic-drawer-demo')
</script>

Overview

Anatomy

MagicDrawer can be used as a single self-contained component or composed from its individual parts for full control.

Simple

vue
<template>
  <magic-drawer id="your-drawer-id">
    <!-- your content -->
  </magic-drawer>
</template>

<script setup>
const { open } = useMagicDrawer('your-drawer-id')
</script>

Composed

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

TIP

MagicDrawerTeleport is optional. Omit it to keep the drawer mounted at all times, with visibility controlled via v-show.

Installation

CLI

Add @maas/vue-equipment to your dependencies.

sh
pnpm install @maas/vue-equipment
sh
npm install @maas/vue-equipment
sh
yarn add @maas/vue-equipment
sh
bun install @maas/vue-equipment

Vue

If you are using Vue, import and add MagicDrawerPlugin to your app.

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

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

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

js
import { onMounted } from 'vue'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'

const { open } = useMagicDrawer('your-drawer-id')

onMounted(() => {
  open()
})

TIP

If you have installed the drawer as a Nuxt module, the composable will be auto-imported and is automatically available in your Nuxt app.

Peer Dependencies

If you haven't installed the required peer dependencies automatically, you'll need to install the following packages manually.

Installation

sh
pnpm install @nuxt/kit @vueuse/core @vueuse/integrations defu focus-trap wheel-gestures
sh
npm install @nuxt/kit @vueuse/core @vueuse/integrations defu focus-trap wheel-gestures
sh
yarn add @nuxt/kit @vueuse/core @vueuse/integrations defu focus-trap wheel-gestures
sh
bun install @nuxt/kit @vueuse/core @vueuse/integrations defu focus-trap wheel-gestures

API Reference

MagicDrawerProvider

The MagicDrawerProvider wraps the drawer and configures all child components according to the provided options.

Props

PropTypeRequired
id
MaybeRef<string>true
options
MagicDrawerOptionsfalse

Options

To customize the drawer, override the necessary options. Any custom options will be merged with the default options.

OptionTypeDefault
position
string
'bottom'
tag
string
'dialog'
focusTrap
boolean | FocusTrapOptionsobject
scrollLock
boolean | objectobject
scrollLock.padding
booleantrue
snapPoints
MagicDrawerSnapPoint[]
[1]
snap.mode
'closest' | 'step''closest'
teleport.target
string'body'
teleport.disabled
booleanfalse
transition.content
string'magic-drawer-content'
transition.backdrop
string'magic-drawer-backdrop'
threshold.lock
number0
threshold.distance
number128
threshold.momentum
number1
animation.snap.duration
number300
animation.snap.easing
function
function
initial.open
booleanfalse
initial.transition
boolean
initial.snapPoint
MagicDrawerSnapPoint
keyListener.close
string[] | false['Escape']
enableMousewheel
booleanfalse
preventZoom
booleantrue
preventDragClose
booleanfalse
disabled
booleanfalse

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

PropTypeRequired
to
string | RendererElementfalse
disabled
booleanfalse

MagicDrawerBackdrop

Renders a full-viewport overlay behind the drawer panel. Closes the drawer when clicked.

CSS Variables

VariableDefault
--magic-drawer-backdrop-positionfixed
--magic-drawer-backdrop-top0
--magic-drawer-backdrop-left0
--magic-drawer-backdrop-right0
--magic-drawer-backdrop-bottom0
--magic-drawer-backdrop-width100%
--magic-drawer-backdrop-height100%
--magic-drawer-backdrop-colorrgba(0, 0, 0, 0.5)
--magic-drawer-backdrop-filterunset
--magic-drawer-backdrop-z-index998

MagicDrawerContent

Renders the drawer panel with drag, snap, and scroll behavior.

CSS Variables

VariableDefault
--magic-drawer-positionfixed
--magic-drawer-inset0
--magic-drawer-displayflex
--magic-drawer-z-index999
--magic-drawer-height75svh
--magic-drawer-width100%
--magic-drawer-max-heightnone
--magic-drawer-max-widthnone
--magic-drawer-content-width100%
--magic-drawer-content-height100%
--magic-drawer-content-max-height100%
--magic-drawer-justify-contentcenter
--magic-drawer-align-itemsflex-end
--magic-drawer-enter-animationslide-btt-in 300ms ease
--magic-drawer-leave-animationslide-btt-out 300ms ease
--magic-drawer-drag-overshoot4rem
--magic-drawer-cursorgrab
--magic-drawer-cursor-dragginggrabbing

MagicDrawerTrigger

Opens or closes the drawer on click.

Props

PropTypeRequired
id
MaybeRef<string>false
disabled
booleanfalse
asChild
booleanfalse

Slot Props

PropTypeDescription
activebooleanWhether the drawer is currently open.
disabledbooleanWhether the trigger is currently disabled.

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

PropTypeRequired
id
MaybeRef<string>true
options
MagicDrawerOptionsfalse

Slots

SlotDescription
defaultContent rendered inside the drawer panel.
backdropContent rendered inside the backdrop element.

Events

The drawer emits the following events through MagicEmitter. Listen to them with useMagicEmitter.

EventPayloadDescription
beforeEnteridFired before the drawer’s open transition starts.
enteridFired when the drawer’s open transition starts.
afterEnteridFired after the drawer’s open transition ends.
beforeLeaveidFired before the drawer’s close transition starts.
leaveidFired when the drawer’s close transition starts.
afterLeaveidFired after the drawer’s close transition ends.
beforeDrag{ id, x, y }Fired when the drawer starts being dragged.
drag{ id, x, y }Fired continuously while the drawer is dragged. x and y are the current translation in pixels.
afterDrag{ id, x, y }Fired when the drawer stops being dragged.
beforeSnap{ id, snapPoint }Fired before the drawer animates to a snap point.
snapTo{ id, snapPoint, duration? }Fired when the drawer is asked to snap programmatically.
afterSnap{ id, snapPoint }Fired after the drawer has settled on a snap point.
willSnapTo{ id, snapPoint }Fired during a drag when the committed snap target changes — i.e. the snap point the drawer will animate to on release. Only fires when the target actually changes, not on every move.
progress{ id, x, y }Fired whenever the drawer’s drag progress changes.

Errors

SourceError CodeMessage
MagicDrawerTeleportmissing_instance_idMagicDrawerTeleport must be nested inside MagicDrawerProvider
MagicDrawerBackdropmissing_instance_idMagicDrawerBackdrop must be nested inside MagicDrawerProvider
MagicDrawerContentmissing_instance_idMagicDrawerContent must be nested inside MagicDrawerProvider
MagicDrawerTriggermissing_instance_idMagicDrawerTrigger must be nested inside MagicDrawerProvider or an id must be provided
MagicDrawerContentovershoot_unit--magic-drawer-drag-overshoot needs to be specified in px or rem

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.

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 '@maas/mirror/vue'
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 '@maas/mirror/vue'
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 '@maas/mirror/vue'
import { useMagicDrawer } from '@maas/vue-equipment/plugins/MagicDrawer'

const snapPoints = ['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 '@maas/mirror/vue'
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 '@maas/mirror/vue'
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>

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 '@maas/mirror/vue'
import {
  MagicDrawerProvider,
  MagicDrawerTeleport,
  MagicDrawerBackdrop,
  MagicDrawerContent,
  MagicDrawerTrigger,
} from '@maas/vue-equipment/plugins/MagicDrawer'
</script>

Will Snap To

The willSnapTo event fires during a drag whenever the committed snap target changes — the snap point the drawer will animate to on release. It only fires when the target actually changes, not on every move.

snap points
0.25
0.5
0.75
1
willSnapTo events
<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-subtle 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-subtle text-xs">{{ point }}</span>
        </div>
      </div>

      <div class="text-surface-subtle 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-subtle'"
          >
            {{ entry.snapPoint }}
          </div>
        </template>
        <span v-else class="text-surface-subtle">—</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 { ref, onBeforeUnmount } from 'vue'
import { MButton } from '@maas/mirror/vue'
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>