MagicTray Soon
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.
<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
Concept
Instead of moving an element like MagicDrawer does, MagicTray keeps its content in place and clips it with clip-path: inset(...). Every side that has snap points becomes draggable. A side's snap value is the inset amount measured from that edge:
0: the side is fully open (no clip)1or100%: the side is fully clipped inward'120px': the side is clipped inward by120px
Snap points are configured per side and can be combined freely. While dragging, a side snaps to the closest snap point in the drag direction once the configured velocity or distance threshold is crossed. If neither threshold is reached, the side animates back to where it started. Drag a side past its outermost snap points and it meets elastic, rubber-band resistance, then springs back on release.
To allow this elastic overdrag in both directions without clipping any content at rest, each draggable edge reserves a band of empty padding, sized by the --magic-tray-drag-overshoot CSS variable, that pushes the content inward. At rest the clip hides this padding, so the content sits flush; the padding is simply the room the edge can bounce into. Note that it adds to the tray's rendered size on draggable edges.
The tray is always rendered inline and stays mounted. It has no open or closed state of its own. If you need overlay behavior, such as teleporting to the body, a backdrop or mount and unmount transitions, compose it with MagicDrawer.
Anatomy
MagicTray can be used as a single self-contained component or composed from its individual parts for full control.
Simple
<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
<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.
pnpm install @maas/vue-equipmentnpm install @maas/vue-equipmentyarn add @maas/vue-equipmentbun install @maas/vue-equipmentVue
If you are using Vue, import and add MagicTrayPlugin to your app.
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.
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.
<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.
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.
| Package |
|---|
@nuxt/kit |
@vueuse/core |
defu |
Installation
pnpm install @nuxt/kit @vueuse/core defunpm install @nuxt/kit @vueuse/core defuyarn add @nuxt/kit @vueuse/core defubun install @nuxt/kit @vueuse/core defuAPI Reference
MagicTrayProvider
The MagicTrayProvider wraps the tray and configures all child components according to the provided options.
Props
| Prop | Type | Required |
|---|---|---|
MaybeRef<string> | true | |
MagicTrayOptions | false |
Options
To customize the tray, override the necessary options. Any custom options will be merged with the default options.
| Option | Type | Default |
|---|---|---|
'div' | ||
{ bottom: [0, 0.5, 1] } | ||
boolean | Partial<Record<TraySide, boolean>> | true | |
number | 0 | |
number | 128 | |
number | 1 | |
number | 300 | |
function | ||
Partial<Record<TraySide, TraySnapPoint>> | — | |
boolean | false |
MagicTrayContent
Renders the clipped content along with drag and snap behavior. Combines all four sides into a single clip-path.
Slots
| Slot | Description |
|---|---|
default | The content that gets clipped. |
background | A layer that fills the full content element, including the reserved overshoot padding. Useful to give the tray a background that stays visible while overdragging. |
handle | Custom handle visuals. Rendered once per draggable side and scoped with the respective side. |
CSS Variables
| Variable | Default |
|---|---|
--magic-tray-radius | 0px |
--magic-tray-drag-overshoot | 3rem |
--magic-tray-position | relative |
--magic-tray-display | inline-block |
--magic-tray-width | max-content |
--magic-tray-height | max-content |
--magic-tray-max-width | none |
--magic-tray-max-height | none |
--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
| Prop | Type | Description |
|---|---|---|
side | The side this handle controls. |
CSS Variables
| Variable | Default |
|---|---|
--magic-tray-handle-size | 1.5rem |
--magic-tray-handle-cursor | ns-resize / ew-resize |
--magic-tray-handle-z-index | 1 |
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
| Prop | Type | Required |
|---|---|---|
MaybeRef<string> | true | |
MagicTrayOptions | false |
Slots
| Slot | Description |
|---|---|
default | The content that gets clipped. |
background | A layer that fills the full content element, including the reserved overshoot padding. |
handle | Custom 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.
| Property | Type | Description |
|---|---|---|
progress | Ref<Record<TraySide, number>> | Per side inset progress, 0 (open) to 1 (fully clipped). |
activeSnapPoint | Ref<Partial<Record<TraySide, TraySnapPoint>>> | The snap point each side is currently snapped to. |
snapTo | (side, snapPoint, duration?) => void | Snap 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.
| Event | Payload | Description |
|---|---|---|
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. |
beforeSnap | { id, side, snapPoint } | Fired before a side animates to a snap point. |
snapTo | { id, side, snapPoint, duration? } | Fired when a side is asked to snap programmatically. |
afterSnap | { id, side, snapPoint } | 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. |
Errors
| Source | Error Code | Message |
|---|---|---|
MagicTrayContent | missing_instance_id | MagicTrayContent 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
<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>