Components

Tray

Source
A morphing multi-view bottom sheet that grows to fit each view.
Install with the CLI
npx @vyui/cli add tray

Overview

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.

Built on the @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.

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>

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.
The scroll region only scrolls once its height is bounded — cap it through the 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.
  • open and view are controllable through v-model:open / v-model:view and default through default-open / default-view.
  • Only the active VyTrayView mounts — inactive views (and their state) unmount.
  • Closing resets navigation to defaultView and clears the back stack, so a reopen starts clean.
  • Drag-to-close, the slide-in/out, and the backdrop are inherited from the core Sheet engine; set dismissible to false to keep it open until dismissed in code.

Props

PropDefaultType
defaultOpenfalseboolean | undefined

Initial open state when uncontrolled.

defaultView"default"string | undefined

Id of the view shown when the tray opens (and returned to after close). Must match a `<VyTrayView :id>`.

dismissibletrueboolean | undefined

Tapping the overlay or dragging down closes the tray.

duration300number | undefined

Height-morph + slide/settle duration in ms. Drives both the per-view height tween and the core sheet's open/close motion.

handletrueboolean | undefined

Show the drag handle at the top of the tray.

keyboardAwarefalseboolean | "scroll" | "lift" | undefined

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

modelValueboolean | undefined

Convenience alias for `open` — bind with `v-model`.

openboolean | undefined

Controlled open state — bind with `v-model:open`.

overlaytrueboolean | undefined

Render the dim overlay behind the tray.

side"bottom"SheetDirection | undefined

Edge the tray slides and drags from. Trays are almost always `bottom`; exposed for parity with the other sheet-family components.

uiPartial<Record<"content" | "body" | "overlay" | "handle" | "footer" | "viewport" | "morph" | "bodyScroll", any>> | undefined
variant"floating" | "flush" | undefined

Panel 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`).

viewstring | undefined

Controlled current view — bind with `v-model:view`. Usually left uncontrolled and driven via triggers / `useTray().setView`.

VyTrayView

PropDefaultType
id*string

Unique 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

EventPayload
update:modelValue[value: boolean]
update:open[value: boolean]
update:view[value: string]

Slots

SlotBindings
triggerTraySlotProps

In-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)`.

defaultTraySlotProps

The views — one or more `<VyTrayView :id>` children.

footerTraySlotProps

Persistent 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 slotPurpose
overlayThe dim backdrop behind the panel.
contentThe sheet panel. Carries the variant chrome (inset/border/radius).
handleThe drag pill at the top of the panel.
morphThe height-animated container. Its transition-duration comes from the duration prop.
viewportInner wrapper measured to drive the morph.
bodyPadding around the active view.
bodyScrollThe keyboard-aware <scroll-view> around the body (rendered when keyboardAware is 'scroll'/true). Cap its height here.
footerThe 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 height via CSS transition (not via main-thread style writes), which is what VyTray uses under the hood.
  • The keyboard rise is driven by the focused input's per-element keyboard event — the reliable keyboard signal under vue-lynx (see KeyboardAware). It is applied as panel padding rather than a transform so it cannot fight the sheet's main-thread drag physics.
  • floating positions 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 Sheet primitives — see Sheet for the underlying behavior.
  • Drawer for a fixed-height, snap-point bottom sheet.
  • Action Sheet for a simple list of actions.
  • Modal for a centered blocking dialog.
  • Island for a floating pill that morphs and expands in place.