Tray
npx @vyui/cli add trayOverview
VyTray is a bottom sheet that hosts several named views and animates its height to fit whichever one is showing — the panel grows into place as you navigate rather than jumping. A back stack (goBack, canGoBack) makes it a natural home for short, branching flows: a menu that drills into a detail, a confirm step, a quick form.
@vyui/coreSheet primitives in fitContent mode, so the panel hugs its content. Reach for Drawer instead when you want a fixed-height, snap-point sheet, or ActionSheet for a simple list of actions.Usage
Give each screen a VyTrayView with a unique id. Only the active view mounts, and the tray measures it and morphs its height to match. Navigate with the controls exposed on the default slot (or useTray).
<script setup lang="ts">
import { ref } from 'vue'
import { VyButton, VyTray, VyTrayView } from '@vyui/kit'
const open = ref(false)
</script>
<template>
<VyTray v-model:open="open" default-view="menu">
<template #trigger>
<VyButton label="Open tray" />
</template>
<template #default="{ setView }">
<VyTrayView id="menu">
<VyButton label="Share" @tap="setView('share')" />
<VyButton label="Delete" @tap="setView('confirm')" />
</VyTrayView>
<VyTrayView id="share">…a taller view; the tray grows…</VyTrayView>
<VyTrayView id="confirm">…a short view; the tray shrinks…</VyTrayView>
</template>
</VyTray>
</template>
The #trigger slot renders in normal flow; tapping it opens the tray to defaultView.
Navigation and the back stack
setView(id) pushes the current view onto a history stack; goBack() pops it. canGoBack reflects whether there is anywhere to return to, so a Back control can hide itself on the first view. The default and footer slots both receive { open, close, setView, goBack, visible, view, canGoBack }.
<template #default="{ setView, goBack, canGoBack }">
<VyTrayView id="confirm">
<VyButton v-if="canGoBack" label="← Back" @tap="goBack()" />
<text>Delete item?</text>
</VyTrayView>
</template>
Persistent footer
The #footer slot mounts outside the morphing region, so it stays put — it does not unmount or re-animate as views change. Ideal for a primary action or a Close button that should be present on every view.
<template #footer="{ close }">
<VyButton label="Close" block @tap="close()" />
</template>
Floating and flush variants
variant sets the panel chrome. floating (the default) is a detached card that hovers with a gap and a border on all sides — the tray's signature look. flush anchors it to the screen edges with a rounded top only, matching a classic Drawer silhouette.
<VyTray v-model:open="open" variant="floating">…</VyTray>
<VyTray v-model:open="open" variant="flush">…</VyTray>
useTray
Any component rendered inside the tray can drive it imperatively with useTray() — handy for custom triggers or deep view bodies that shouldn't thread callbacks through props.
<script setup lang="ts">
import { useTray } from '@vyui/kit'
const tray = useTray()
// tray.setView('confirm'); tray.goBack(); tray.close()
</script>
Keyboard awareness
Set keyboard-aware and the whole panel rises above the on-screen keyboard when an input inside it gains focus (Lynx; no-op on web). Because the panel is bottom-anchored and hugs its content, the tray grows its bottom padding by the keyboard height — handle, body, and footer all clear the keyboard while the panel background fills in behind it. Any VyInput or VyTextarea in the body or footer registers itself automatically; no per-input wrapping.
<VyTray v-model:open="open" keyboard-aware="lift">
<VyInput v-model="reply" placeholder="Type a reply…" />
<template #footer="{ close }">
<VyButton label="Send" block @tap="close()" />
</template>
</VyTray>
Two modes:
keyboard-aware="lift"— rise only. Right for short and medium trays whose content fits above the keyboard.keyboard-aware/keyboard-aware="scroll"— rise, plus the body becomes a keyboard-aware scroll region that keeps the focused input in view. Right for tall content like a multi-field form.
bodyScroll ui slot (e.g. :ui="{ bodyScroll: 'max-h-80' }"). Left unbounded, the tray hugs its content and there is nothing to scroll.<VyTray v-model:open="open" keyboard-aware :ui="{ bodyScroll: 'max-h-80' }">
<VyInput v-model="form.name" placeholder="Name" />
<VyTextarea v-model="form.bio" placeholder="Bio" />
<VyInput v-model="form.website" placeholder="Website" />
<template #footer>
<VyInput v-model="form.note" placeholder="Footer note" />
</template>
</VyTray>
Features and behavior
- The panel height morphs between views via CSS
transition: height; measured per view, so content of any height is supported. openandvieware controllable throughv-model:open/v-model:viewand default throughdefault-open/default-view.- Only the active
VyTrayViewmounts — inactive views (and their state) unmount. - Closing resets navigation to
defaultViewand clears the back stack, so a reopen starts clean. - Drag-to-close, the slide-in/out, and the backdrop are inherited from the core
Sheetengine; setdismissibletofalseto keep it open until dismissed in code.
Props
| Prop | Default | Type |
|---|---|---|
defaultOpen | false | boolean | undefinedInitial open state when uncontrolled. |
defaultView | "default" | string | undefinedId of the view shown when the tray opens (and returned to after close). Must match a `<VyTrayView :id>`. |
dismissible | true | boolean | undefinedTapping the overlay or dragging down closes the tray. |
duration | 300 | number | undefinedHeight-morph + slide/settle duration in ms. Drives both the per-view height tween and the core sheet's open/close motion. |
handle | true | boolean | undefinedShow the drag handle at the top of the tray. |
keyboardAware | false | boolean | "scroll" | "lift" | undefinedKeyboard handling on Lynx (no-op on web/jsdom — there is no platform keyboard event). When enabled, the whole panel rises above the on-screen keyboard: the panel is bottom-anchored and content-hugging, so growing its bottom padding by the keyboard height pushes handle/body/footer up while the panel background fills behind the keyboard. (Padding, not `transform` — the sheet's MT drag worklets own the panel transform.) - `'lift'` — rise only. Best for short/medium trays. - `'scroll'` (or `true`) — rise, plus the body becomes a keyboard-aware scroll region that keeps the focused input in view. It only scrolls once its height is bounded — cap it via the `bodyScroll` ui slot (e.g. `max-h-*`). Best for tall trays. - `false` — no keyboard handling. Inputs anywhere inside (body or footer) register themselves; no `KeyboardAwareTrigger` wrapping needed. |
modelValue | — | boolean | undefinedConvenience alias for `open` — bind with `v-model`. |
open | — | boolean | undefinedControlled open state — bind with `v-model:open`. |
overlay | true | boolean | undefinedRender the dim overlay behind the tray. |
side | "bottom" | SheetDirection | undefinedEdge the tray slides and drags from. Trays are almost always `bottom`; exposed for parity with the other sheet-family components. |
ui | — | Partial<Record<"content" | "body" | "overlay" | "handle" | "footer" | "viewport" | "morph" | "bodyScroll", any>> | undefined |
variant | — | "floating" | "flush" | undefinedPanel chrome. `floating` is a detached card hovering with a gap and border on all sides (the tray's signature look); `flush` is a classic bottom sheet glued to the screen edges (same silhouette as `Drawer`). |
view | — | string | undefinedControlled current view — bind with `v-model:view`. Usually left uncontrolled and driven via triggers / `useTray().setView`. |
VyTrayView
| Prop | Default | Type |
|---|---|---|
id* | — | stringUnique id for this view. The tray shows this view's content only when its current `view` matches — set it via `defaultView`, `useTray().setView(id)`, or a trigger's `view` prop. |
Emits
| Event | Payload |
|---|---|
update:modelValue | [value: boolean] |
update:open | [value: boolean] |
update:view | [value: string] |
Slots
| Slot | Bindings |
|---|---|
trigger | TraySlotPropsIn-flow trigger. Rendered where `<VyTray>` sits; tapping it opens the tray to `defaultView`. For opening to a specific view, use `useTray()` or a `@tap` that calls the slot's `open(id)`. |
default | TraySlotPropsThe views — one or more `<VyTrayView :id>` children. |
footer | TraySlotPropsPersistent footer, mounted below the morph region so it survives view swaps. |
Styling and theming
Override globally through appConfig.ui.tray or locally with the ui prop.
| UI slot | Purpose |
|---|---|
overlay | The dim backdrop behind the panel. |
content | The sheet panel. Carries the variant chrome (inset/border/radius). |
handle | The drag pill at the top of the panel. |
morph | The height-animated container. Its transition-duration comes from the duration prop. |
viewport | Inner wrapper measured to drive the morph. |
body | Padding around the active view. |
bodyScroll | The keyboard-aware <scroll-view> around the body (rendered when keyboardAware is 'scroll'/true). Cap its height here. |
footer | The persistent footer region. |
Accessibility
The panel announces as a dialog and traps focus, inherited from the core SheetContent. Give icon-only navigation controls an accessibility-label, and make sure a Back control is reachable when canGoBack is true. Because views mount and unmount as you navigate, test the morphing flow with VoiceOver and TalkBack to confirm focus lands sensibly on each view.
Platform notes
- The height morph relies on animating between concrete pixel heights; Lynx animates
heightvia CSStransition(not via main-thread style writes), which is whatVyTrayuses under the hood. - The keyboard rise is driven by the focused input's per-element
keyboardevent — the reliable keyboard signal under vue-lynx (seeKeyboardAware). It is applied as panel padding rather than a transform so it cannot fight the sheet's main-thread drag physics. floatingpositions the panel with inset utilities that override the core edge-anchored sheet rules; on Lynx the later-injected utilities win.- Open/close motion and drag physics come from the core
Sheetprimitives — seeSheetfor the underlying behavior.
Related components
Drawerfor a fixed-height, snap-point bottom sheet.Action Sheetfor a simple list of actions.Modalfor a centered blocking dialog.Islandfor a floating pill that morphs and expands in place.