Skip to content
Sponsored by

MagicTray

MagicTray is a flexible, touch enabled, unstyled component that clips its content with a draggable, snapping clip path. Useful for things like peeking panels, reveal effects, resizable previews and the like.

Drag the bottom edge
<template>
  <magic-tray
    id="magic-tray-default-demo"
    :options="{
      snapPoints: { bottom: [0, 0.2, 0.4] },
      threshold: { distance: 32 },
    }"
    style="--magic-tray-radius: 0.5rem"
  >
    <template #background>
      <div class="bg-surface-base h-full w-full" />
    </template>
    <div
      class="flex aspect-16/9 w-120 max-w-full items-center justify-center select-none"
    >
      <span>Drag the bottom edge</span>
    </div>
  </magic-tray>
</template>

Overview

Anatomy

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

Simple

vue
<template>
  <magic-tray
    id="your-tray-id"
    :options="{ snapPoints: { bottom: [0, 0.5, 1] } }"
  >
    <!-- your content -->
  </magic-tray>
</template>

<script setup>
const { snapTo } = useMagicTray('your-tray-id')
</script>

Composed

vue
<template>
  <magic-tray-provider id="your-tray-id" :options="options">
    <magic-tray-content>
      <!-- your content -->
      <template #handle="{ side }">
        <!-- custom handle for `side` -->
      </template>
    </magic-tray-content>
  </magic-tray-provider>
</template>

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 MagicTrayPlugin to your app.

js
import { createApp } from 'vue'
import { MagicTrayPlugin } from '@maas/vue-equipment/plugins/MagicTray'

const app = createApp({})

app.use(MagicTrayPlugin)

Nuxt

The tray is available as a Nuxt module. In your Nuxt config file add @maas/vue-equipment/nuxt to your modules and add MagicTray to the plugins in your configuration.

js
export default defineNuxtConfig({
  modules: ['@maas/vue-equipment/nuxt'],
  vueEquipment: {
    plugins: ['MagicTray'],
  },
})

Direct Import

If you prefer a more granular approach, components can be directly imported.

vue
<script setup>
import {
  MagicTrayProvider,
  MagicTrayContent,
  MagicTrayHandle,
} from '@maas/vue-equipment/plugins/MagicTray'
</script>

Composable

In order to interact with the tray from anywhere within your app, we provide a useMagicTray composable. Import it directly when needed.

js
import { onMounted } from 'vue'
import { useMagicTray } from '@maas/vue-equipment/plugins/MagicTray'

const { snapTo } = useMagicTray('your-tray-id')

onMounted(() => {
  snapTo('bottom', 0.5)
})

TIP

If you have installed the tray 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 defu
sh
npm install @nuxt/kit @vueuse/core defu
sh
yarn add @nuxt/kit @vueuse/core defu
sh
bun install @nuxt/kit @vueuse/core defu

API Reference

MagicTrayProvider

The MagicTrayProvider wraps the tray and configures all child components according to the provided options.

Props

PropTypeRequired
id
MaybeRef<string>true
options
MagicTrayOptionsfalse

Options

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

OptionTypeDefault
tag
string
'div'
snapPoints
TraySnapPoints
{ bottom: [0, 0.5, 1] }
snap.mode
'closest' | 'step''closest'
snap.instant
boolean | Partial<Record<TraySide, boolean>>false
handles
boolean | Partial<Record<TraySide, boolean>>true
magnetism.sides
false | Partial<Record<TraySide, Partial<Record<TraySnapPoint, 'inner' | 'outer' | 'both'>>>>false
magnetism.radius
number | { start?: number; stop?: number }0
magnetism.pull
number0
magnetism.easing
EasingKey'easeInOutQuad'
magnetism.virtual
booleanfalse
threshold.lock
number0
threshold.distance
number128
threshold.momentum
number1
animation.snap.duration
number300
animation.snap.easing
function
function
initial.snapPoints
Partial<Record<TraySide, TraySnapPoint>>
initial.transition
booleanfalse
disabled
booleanfalse
inset
booleanfalse

MagicTrayContent

Renders the clipped content along with drag and snap behavior. Combines all four sides into a single clip-path.

Slots

SlotDescription
defaultThe content that gets clipped.
backgroundA layer that fills the full content element, including the reserved overshoot padding. Useful to give the tray a background that stays visible while overdragging.
handleCustom handle visuals. Rendered once per draggable side and scoped with the respective side.

CSS Variables

VariableDefault
--magic-tray-radius0px
--magic-tray-radius-top-leftvar(--magic-tray-radius)
--magic-tray-radius-top-rightvar(--magic-tray-radius)
--magic-tray-radius-bottom-rightvar(--magic-tray-radius)
--magic-tray-radius-bottom-leftvar(--magic-tray-radius)
--magic-tray-clip-top
--magic-tray-clip-right
--magic-tray-clip-bottom
--magic-tray-clip-left
--magic-tray-drag-overshoot3rem
--magic-tray-drag-overshoot-outervar(--magic-tray-drag-overshoot)
--magic-tray-drag-overshoot-innervar(--magic-tray-drag-overshoot)
--magic-tray-positionrelative
--magic-tray-displayinline-block
--magic-tray-widthmax-content
--magic-tray-heightmax-content
--magic-tray-max-widthnone
--magic-tray-max-heightnone
--magic-tray-bg-z-index-1

MagicTrayHandle

Renders an invisible, draggable hit area along an edge. Has no appearance of its own — provide visuals through the handle slot. Rendered automatically by MagicTrayContent for each draggable side.

Slot Props

PropTypeDescription
side
TraySide
The side this handle controls.

CSS Variables

VariableDefault
--magic-tray-handle-width-x100%
--magic-tray-handle-width-y4rem
--magic-tray-handle-width-topvar(--magic-tray-handle-width-x)
--magic-tray-handle-width-bottomvar(--magic-tray-handle-width-x)
--magic-tray-handle-width-leftvar(--magic-tray-handle-width-y)
--magic-tray-handle-width-rightvar(--magic-tray-handle-width-y)
--magic-tray-handle-height-x4rem
--magic-tray-handle-height-y100%
--magic-tray-handle-height-topvar(--magic-tray-handle-height-x)
--magic-tray-handle-height-bottomvar(--magic-tray-handle-height-x)
--magic-tray-handle-height-leftvar(--magic-tray-handle-height-y)
--magic-tray-handle-height-rightvar(--magic-tray-handle-height-y)
--magic-tray-handle-offset-x-top0%
--magic-tray-handle-offset-x-right50%
--magic-tray-handle-offset-x-bottom0%
--magic-tray-handle-offset-x-left-50%
--magic-tray-handle-offset-y-top-50%
--magic-tray-handle-offset-y-right0%
--magic-tray-handle-offset-y-bottom50%
--magic-tray-handle-offset-y-left0%
--magic-tray-handle-cursorgrab
--magic-tray-handle-cursor-dragginggrabbing
--magic-tray-handle-z-index1

MagicTrayTransform

Translates a nested layer alongside the clip. As an edge is dragged inward, the wrapped content is pushed away from that edge by the same distance, while a background layer stays anchored.

Props

PropTypeDefault
axis
TrayTransformAxis
'both'
factor
number1
disabled
booleanfalse

MagicTray

A self-contained component that composes the provider and content internally. Use this for simple, inline cases where you don’t need custom markup.

Props

PropTypeRequired
id
MaybeRef<string>true
options
MagicTrayOptionsfalse

Slots

SlotDescription
defaultThe content that gets clipped.
backgroundA layer that fills the full content element, including the reserved overshoot padding.
handleCustom handle visuals. Rendered once per draggable side and scoped with the respective side.

useMagicTray

The composable returns the tray’s reactive state and a set of functions to control it programmatically.

PropertyTypeDescription
progressRef<Record<TraySide, number>>Per side inset progress, 0 (open) to 1 (fully clipped).
activeSnapPointRef<Partial<Record<TraySide, TraySnapPoint>>>The snap point each side is currently snapped to.
snapTo(side, snapPoint, duration?) => voidSnap a single side to a snap point, optionally overriding the animation duration.

Events

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

EventPayloadDescription
beforeDrag{ id, side, value }Fired when a side starts being dragged.
drag{ id, side, value }Fired continuously while a side is dragged. value is the current inset in pixels.
afterDrag{ id, side, value }Fired when a side stops being dragged.
staticClick{ id, side, value }Fired instead of a drag when a handle is pressed and released without crossing threshold.lock — e.g. to snap a side programmatically on tap.
beforeSnap{ id, snapPoint: { side, point } }Fired before a side animates to a snap point.
snapTo{ id, snapPoint: { side, point }, duration? }Fired when a side is asked to snap programmatically.
afterSnap{ id, snapPoint: { side, point } }Fired after a side has settled on a snap point.
progress{ id, side, value }Fired whenever a side’s inset progress changes. value is between 0 and 1.
magnet{ id, side, value }Fired whenever a magnetic pull changes, e.g. to drive a parallax in the handle slot. value is the pull as a fraction of magnetism.pull, signed by direction: positive when pulled inward, negative when pulled outward, 0 at rest.
willSnapTo{ id, side, snapPoint }Fired during a drag when the committed snap target changes — i.e. the snap point the edge will animate to on release. Only fires when the target actually changes, not on every move.

Errors

SourceError CodeMessage
MagicTrayContentmissing_instance_idMagicTrayContent must be nested inside MagicTrayProvider
MagicTrayTransformmissing_instance_idMagicTrayTransform must be nested inside MagicTrayProvider

Caveats

The tray handles situations where dragging and scrolling might interfere with each other on touch devices. In order for the tray to differentiate when the user scrolls and when the user drags, any scrollable containers within the tray need to have their overflow value explicitly set to auto or scroll.

Examples

Combined Sides

Drag any edge
<template>
  <magic-tray
    id="magic-tray-combined-demo"
    :options="{
      snapPoints: {
        top: [0, 0.2, 0.4],
        right: [0, 0.2, 0.4],
        bottom: [0, 0.2, 0.4],
        left: [0, 0.2, 0.4],
      },
      threshold: {
        distance: 32,
      },
    }"
    style="--magic-tray-radius: 0.75rem"
  >
    <template #background>
      <div class="bg-surface-base h-full w-full" />
    </template>
    <div
      class="flex aspect-16/9 w-120 max-w-full items-center justify-center select-none"
    >
      <span>Drag any edge</span>
    </div>
  </magic-tray>
</template>

Transform

Translate a layer alongside the clip with MagicTrayTransform, pushing content away from each edge as it is dragged in.

Drag the right or bottom edge

<template>
  <magic-tray
    id="magic-tray-transform-demo"
    :options="{
      snapPoints: {
        right: [0, 0.25, 0.5],
        bottom: [0, 0.25, 0.5],
      },
      threshold: { distance: 32 },
    }"
    style="--magic-tray-radius: 0.75rem"
  >
    <template #background>
      <div class="bg-surface-base h-full w-full" />
    </template>
    <magic-tray-transform :factor="0.5">
      <div
        class="flex aspect-16/9 w-120 max-w-full flex-col items-center justify-center gap-8 p-8 select-none"
      >
        <p class="text-surface max-w-xs text-center">
          Drag the right or bottom edge
        </p>
      </div>
    </magic-tray-transform>
  </magic-tray>
</template>

Reveal

A composed tray layered over its own container, easing into its initial snap points on mount via initial.transition.

LR
Drag left or right edge
<template>
  <div
    class="bg-surface-low text-surface-subtle relative flex aspect-16/9 w-120 max-w-full items-center justify-between rounded-[0.25rem] select-none"
  >
    <span class="w-16 text-center">L</span>
    <span class="w-16 text-center">R</span>
    <magic-tray-provider :id="id" :options="options">
      <magic-tray-content
        :style="{
          '--magic-tray-radius': radius,
          '--magic-tray-drag-overshoot-outer': '3.5rem',
        }"
      >
        <template #background>
          <div class="bg-surface-base h-full w-full" />
        </template>
        <div
          class="text-surface flex h-full w-full items-center justify-center p-8 text-center"
        >
          Drag left or right edge
        </div>
      </magic-tray-content>
    </magic-tray-provider>
  </div>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import { useMagicTray } from '@maas/vue-equipment/plugins/MagicTray'
import type {
  MagicTrayOptions,
  TraySide,
} from '@maas/vue-equipment/plugins/MagicTray'

const id = 'magic-tray-reveal-demo'
const sides: TraySide[] = ['top', 'right', 'bottom', 'left']

const options: MagicTrayOptions = {
  snapPoints: {
    top: ['8px'],
    right: ['64px', '128px'],
    bottom: ['8px'],
    left: ['64px', '128px'],
  },
  initial: {
    snapPoints: { top: '8px', right: '64px', bottom: '8px', left: '64px' },
    transition: true,
  },
  threshold: { distance: 32 },
  inset: true,
}

const { state } = useMagicTray(id, options)

// Roundedness tracks the first 8px of clip, maxing out at 0.5rem
const radius = computed(() => {
  const clip = (side: TraySide) =>
    Math.max(0, state.dragged[side] - state.overshoot.outer[side])
  const peek = Math.max(...sides.map(clip))
  return `${Math.min(1, peek / 8) * 0.5}rem`
})
</script>

Step Snapping

With snap.mode set to 'step', the edge 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.

Drag the bottom edge
<template>
  <magic-tray
    id="magic-tray-step-snap-demo"
    :options="{
      snapPoints: { bottom: [0, 0.25, 0.5, 0.75, 1] },
      snap: { mode: 'step' },
      threshold: { distance: 32 },
    }"
    style="--magic-tray-radius: 0.5rem"
  >
    <template #background>
      <div class="bg-surface-base h-full w-full" />
    </template>
    <div
      class="flex aspect-16/9 w-120 max-w-full items-center justify-center select-none"
    >
      <span>Drag the bottom edge</span>
    </div>
  </magic-tray>
</template>

Instant Snap

With snap.instant set to true, the edge no longer follows the pointer freely — it always rests exactly on whichever snap point is geometrically closest to the pointer, updating the moment that changes, with no animation and no waiting for release. It ignores threshold and snap.mode, both of which are about how the tray settles after a free drag ends — a concept that doesn't apply once dragging is the snapping.

Drag the bottom edge
<template>
  <magic-tray
    id="magic-tray-instant-snap-demo"
    :options="{
      snapPoints: { bottom: [0, 0.25, 0.5, 0.75, 1] },
      snap: { instant: true },
    }"
    style="--magic-tray-radius: 0.5rem"
  >
    <template #background>
      <div class="bg-surface-base h-full w-full" />
    </template>
    <div
      class="flex aspect-16/9 w-120 max-w-full items-center justify-center select-none"
    >
      <span>Drag the bottom edge</span>
    </div>
  </magic-tray>
</template>

Will Snap To

The willSnapTo event fires during a drag whenever the committed snap target changes — the snap point the edge will animate to on release. It only fires when the target actually changes, not on every move, making it efficient for driving UI reactions like previewing the next state or triggering haptics at the moment the user has committed to a position.

LR
Drag the left or right edge
snap points
left
0
0.25
0.5
0.75
1
right
1
0.75
0.5
0.25
0
willSnapTo events
<template>
  <div class="flex flex-col items-center gap-4 select-none">
    <div
      class="bg-surface-low text-surface-subtle relative flex aspect-16/9 w-120 max-w-full items-center justify-between overflow-hidden rounded-[0.25rem]"
    >
      <span class="w-16 text-center">L</span>
      <span class="w-16 text-center">R</span>
      <magic-tray-provider :id="id" :options="options">
        <magic-tray-content
          :style="{
            '--magic-tray-radius': '0.5rem',
            '--magic-tray-drag-overshoot-outer': '3.5rem',
          }"
        >
          <template #background>
            <div class="bg-surface-base h-full w-full" />
          </template>
          <div
            class="text-surface flex h-full w-full items-center justify-center p-8 text-center"
          >
            Drag the left or right edge
          </div>
        </magic-tray-content>
      </magic-tray-provider>
    </div>

    <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 flex-col gap-3">
        <div
          v-for="side in ['left', 'right']"
          :key="side"
          class="flex items-center gap-3"
        >
          <span class="text-surface-subtle w-8 text-xs">{{ side }}</span>
          <div class="flex flex-1 justify-between">
            <div
              v-for="point in side === 'right'
                ? [...snapPointKeys].reverse()
                : snapPointKeys"
              :key="point"
              class="flex flex-col items-center gap-2"
            >
              <div
                class="h-2 w-2 rounded-full"
                :class="dotClass(side, point)"
              />
              <span class="text-surface-subtle text-xs">{{ point }}</span>
            </div>
          </div>
        </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="flex justify-between"
            :class="i === 0 ? 'text-surface' : 'text-surface-subtle'"
          >
            <span>
              <span class="opacity-50">{{ entry.side }}</span>
              {{ entry.snapPoint }}
            </span>
            <span>{{ directionArrow(entry.side, entry.snapPoint) }}</span>
          </div>
        </template>
        <span v-else class="text-surface-subtle">—</span>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, reactive, onBeforeUnmount } from 'vue'
import { useMagicTray } from '@maas/vue-equipment/plugins/MagicTray'
import { useMagicEmitter } from '@maas/vue-equipment/plugins/MagicEmitter'
import type {
  MagicTrayOptions,
  TraySnapPoint,
  TraySnapPointPayload,
  TrayWillSnapToPayload,
} from '@maas/vue-equipment/plugins/MagicTray'

const id = 'magic-tray-will-snap-to-demo'
const snapPointKeys = [0, 0.25, 0.5, 0.75, 1]

const options: MagicTrayOptions = {
  snapPoints: {
    left: snapPointKeys,
    right: snapPointKeys,
  },
  threshold: { distance: 32 },
  inset: true,
}

const { activeSnapPoint } = useMagicTray(id, options)

const emitter = useMagicEmitter()

// The pending snap target per side during drag — null when not dragging
const willSnapTo = reactive<Record<string, TraySnapPoint | null>>({
  left: null,
  right: null,
})

function dotClass(side: string, point: number) {
  if (willSnapTo[side] === point) {
    return 'bg-warning-solid'
  }
  if (
    (activeSnapPoint.value as Record<string, TraySnapPoint | undefined>)[
      side
    ] === point
  ) {
    return 'bg-success-solid'
  }
  return 'bg-surface-base'
}

// Arrow reflects the spatial direction on screen
function directionArrow(side: string, snapPoint: TraySnapPoint) {
  const p = Number(snapPoint)
  return side === 'right' ? (p === 0 ? '→' : '←') : p === 0 ? '←' : '→'
}

const log = ref<TrayWillSnapToPayload[]>([])

function onWillSnapTo(payload: TrayWillSnapToPayload) {
  if (payload.id !== id) {
    return
  }
  willSnapTo[payload.side] = payload.snapPoint
  log.value.unshift(payload)
  if (log.value.length > 5) {
    log.value.length = 5
  }
}

function isTraySnapPointPayload(v: unknown): v is TraySnapPointPayload {
  return typeof v === 'object' && v !== null && 'side' in v && 'point' in v
}

function onAfterSnap(payload: { id: string; snapPoint: unknown }) {
  if (payload.id !== id) {
    return
  }
  if (isTraySnapPointPayload(payload.snapPoint)) {
    willSnapTo[payload.snapPoint.side] = null
  }
}

emitter.on('willSnapTo', onWillSnapTo)
emitter.on('afterSnap', onAfterSnap)

onBeforeUnmount(() => {
  emitter.off('willSnapTo', onWillSnapTo)
  emitter.off('afterSnap', onAfterSnap)
})
</script>

Static Click

The staticClick event fires on a handle when it is pressed and released without crossing threshold.lock — a tap rather than a drag. Use it to trigger a programmatic snapTo, e.g. toggling a side open and closed, without any of the handle's own drag logic getting in the way.

LR
Tap the left or right edge to toggle it, drag to move freely
staticClick events
<template>
  <div class="flex flex-col items-center gap-4 select-none">
    <div
      class="bg-surface-low text-surface-subtle relative flex aspect-16/9 w-120 max-w-full items-center justify-between overflow-hidden rounded-[0.25rem]"
    >
      <span class="w-16 text-center">L</span>
      <span class="w-16 text-center">R</span>
      <magic-tray-provider :id="id" :options="options">
        <magic-tray-content :style="{ '--magic-tray-radius': '0.5rem' }">
          <template #background>
            <div class="bg-surface-base h-full w-full" />
          </template>
          <div
            class="text-surface flex h-full w-full items-center justify-center p-8 text-center"
          >
            Tap the left or right edge to toggle it, drag to move freely
          </div>
        </magic-tray-content>
      </magic-tray-provider>
    </div>

    <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-2 text-xs tracking-widest uppercase">
        staticClick 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="flex justify-between"
            :class="i === 0 ? 'text-surface' : 'text-surface-subtle'"
          >
            <span>{{ entry.side }}</span>
            <span>{{ Math.round(entry.value) }}px</span>
          </div>
        </template>
        <span v-else class="text-surface-subtle">—</span>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onBeforeUnmount } from 'vue'
import { useMagicTray } from '@maas/vue-equipment/plugins/MagicTray'
import { useMagicEmitter } from '@maas/vue-equipment/plugins/MagicEmitter'
import type {
  MagicTrayOptions,
  TraySidePayload,
} from '@maas/vue-equipment/plugins/MagicTray'

const id = 'magic-tray-static-click-demo'

const options: MagicTrayOptions = {
  snapPoints: { left: [0, 0.25], right: [0, 0.25] },
  threshold: { distance: 32 },
  inset: true,
}

const { snapTo, activeSnapPoint } = useMagicTray(id, options)
const emitter = useMagicEmitter()

const log = ref<({ id: string } & TraySidePayload)[]>([])

// Toggle the tapped side between its open and closed snap points
function onStaticClick(payload: { id: string } & TraySidePayload) {
  if (payload.id !== id) {
    return
  }

  log.value.unshift(payload)
  if (log.value.length > 5) {
    log.value.length = 5
  }

  const current = activeSnapPoint.value[payload.side]
  snapTo(payload.side, current === 0 ? 1 : 0)
}

emitter.on('staticClick', onStaticClick)

onBeforeUnmount(() => {
  emitter.off('staticClick', onStaticClick)
})
</script>

Magnetic

With magnetism.sides, an edge is pulled toward the cursor as it nears the handle, gaining as it closes in. Each snap point sets its direction: ’inner’ pulls in from inside, ’outer’ out from outside, ’both’ from either side.

TIP

If you move the handle with --magic-tray-handle-offset-*, set magnetism.radius and magnetism.pull manually. Left at their defaults they derive from the handle’s position.

LR
Move toward the right edge
<template>
  <div
    class="bg-surface-low text-surface-subtle relative flex aspect-16/9 w-120 max-w-full items-center justify-between overflow-hidden rounded-[0.25rem] select-none"
  >
    <span class="w-16 text-center">L</span>
    <span class="w-16 text-center">R</span>
    <magic-tray-provider :id="id" :options="options">
      <magic-tray-content
        :style="{
          '--magic-tray-radius': '0.5rem',
          '--magic-tray-drag-overshoot-outer': '3.5rem',
          '--magic-tray-handle-width-y': '256px',
        }"
      >
        <template #background>
          <div class="bg-surface-base h-full w-full" />
        </template>
        <div
          class="text-surface flex h-full w-full items-center justify-center p-8 text-center"
        >
          Move toward the right edge
        </div>
      </magic-tray-content>
    </magic-tray-provider>
  </div>
</template>

<script lang="ts" setup>
import { useMagicTray } from '@maas/vue-equipment/plugins/MagicTray'
import type { MagicTrayOptions } from '@maas/vue-equipment/plugins/MagicTray'

const id = 'magic-tray-magnetic-demo'

const options: MagicTrayOptions = {
  snapPoints: {
    right: [0, 0.5, 1],
  },
  snap: { mode: 'step' },
  magnetism: {
    sides: {
      right: { 0: 'inner', 0.5: 'both', 1: 'outer' },
    },
  },
  threshold: { distance: 24 },
  inset: true,
}

useMagicTray(id, options)
</script>

Virtual Magnetism

With magnetism.virtual set to true, all magnetism calculations run as normal — geometry, scrubbing, latching, and settle animations — but nothing is written to state.magnetic. The clip path and handle transforms stay frozen while the magnet event still fires with the full computed pull value. Use this to drive custom effects, parallax, or UI reactions purely through events.

Move toward the left or right edge
[clip stays frozen]
magnet event
left 0.000 right 0.000
state
activeSnapPoint.left
progress.left0.000
activeSnapPoint.right
progress.right0.000
<template>
  <div class="flex flex-col items-center gap-4 select-none">
    <div
      class="bg-surface-low text-surface-subtle relative flex aspect-16/9 w-120 max-w-full items-center justify-between overflow-hidden rounded-[0.25rem]"
    >
      <magic-tray-provider :id="id" :options="options">
        <magic-tray-content
          :style="{
            '--magic-tray-radius': '0.25rem',
            '--magic-tray-drag-overshoot-outer': '3.5rem',
            '--magic-tray-handle-offset-x-left': 'calc(-50% + 0.5rem)',
            '--magic-tray-handle-offset-x-right': 'calc(50% - 0.5rem)',
          }"
        >
          <template #handle-left>
            <div
              class="h-[calc(2.5rem+var(--handle-v,0)*1rem)] w-1 rounded-full bg-[#fff] opacity-[calc(0.1+var(--handle-v,0)*0.9)]"
              :style="{ '--handle-v': handleV('left') }"
            />
          </template>
          <template #handle-right>
            <div
              class="h-[calc(2.5rem+var(--handle-v,0)*1rem)] w-1 rounded-full bg-[#fff] opacity-[calc(0.1+var(--handle-v,0)*0.9)]"
              :style="{ '--handle-v': handleV('right') }"
            />
          </template>
          <template #background>
            <div class="bg-surface-base h-full w-full" />
          </template>
          <div
            class="text-surface flex h-full w-full items-center justify-center p-8 text-center"
          >
            Move toward the left or right edge<br />
            [clip stays frozen]
          </div>
        </magic-tray-content>
      </magic-tray-provider>
    </div>

    <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">
        magnet event
      </div>
      <div class="mb-1.5 flex justify-between text-xs">
        <span class="text-surface-subtle">
          left
          <span class="text-surface">{{ magnetValues.left.toFixed(3) }}</span>
        </span>
        <span class="text-surface-subtle">
          right
          <span class="text-surface">{{ magnetValues.right.toFixed(3) }}</span>
        </span>
      </div>
      <div class="bg-surface-base relative h-1 overflow-hidden rounded-full">
        <div
          class="bg-success-solid absolute right-1/2 h-full w-[calc(var(--bar-v,0)*50%)] opacity-(--bar-v,0)"
          :style="{ '--bar-v': magnetValues.left }"
        />
        <div
          class="bg-success-solid absolute left-1/2 h-full w-[calc(var(--bar-v,0)*50%)] opacity-(--bar-v,0)"
          :style="{ '--bar-v': magnetValues.right }"
        />
        <div
          class="bg-surface-subtle absolute top-0 left-1/2 h-full w-px -translate-x-1/2"
        />
      </div>

      <div
        class="text-surface-subtle mt-4 mb-3 text-xs tracking-widest uppercase"
      >
        state
      </div>
      <div class="flex flex-col gap-1.5">
        <template v-for="side in sides" :key="side">
          <div class="flex justify-between">
            <span class="text-surface-subtle">activeSnapPoint.{{ side }}</span>
            <span>{{ state.activeSnapPoint[side] ?? '—' }}</span>
          </div>
          <div class="flex justify-between">
            <span class="text-surface-subtle">progress.{{ side }}</span>
            <span>{{ state.progress[side].toFixed(3) }}</span>
          </div>
        </template>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { reactive, onBeforeUnmount } from 'vue'
import { useMagicTray } from '@maas/vue-equipment/plugins/MagicTray'
import { useMagicEmitter } from '@maas/vue-equipment/plugins/MagicEmitter'
import type {
  MagicTrayOptions,
  TraySide,
} from '@maas/vue-equipment/plugins/MagicTray'

const id = 'magic-tray-virtual-magnetic-demo'
const sides: TraySide[] = ['left', 'right']

const options: MagicTrayOptions = {
  snapPoints: {
    left: [0, 0.25],
    right: [0, 0.25],
  },
  snap: { mode: 'step' },
  magnetism: {
    sides: {
      left: { 0: 'inner', 0.25: 'inner' },
      right: { 0: 'inner', 0.25: 'inner' },
    },
    radius: { start: 32, stop: 8 },
    virtual: true,
  },
  threshold: { distance: 32 },
  inset: true,
}

const { state } = useMagicTray(id, options)
const emitter = useMagicEmitter()

const magnetValues = reactive<Record<TraySide, number>>({
  top: 0,
  right: 0,
  bottom: 0,
  left: 0,
})

function handleV(side: TraySide) {
  return state.draggingSide === side ? 1 : magnetValues[side]
}

function onMagnet(payload: { id: string; side: TraySide; value: number }) {
  if (payload.id === id) {
    magnetValues[payload.side] = payload.value
  }
}

emitter.on('magnet', onMagnet)

onBeforeUnmount(() => {
  emitter.off('magnet', onMagnet)
})
</script>