Sheet

A panel that rises from the bottom of the screen and can be dragged shut.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Link2, Mail, MessageCircle, QrCode } from '@lucide/vue'
  import { Button, ListItem, List, Sheet, Stack, Text } from '@hina-ui/vue'

  const targets = [
    { label: 'Copy link', icon: Link2 },
    { label: 'Direct message', icon: MessageCircle },
    { label: 'Email', icon: Mail },
    { label: 'QR code', icon: QrCode },
  ]
  const picked = ref('')
</script>

<template>
  <Stack gap="sm" align="start">
    <Sheet title="Share this article" description="Pick where it goes.">
      <Button variant="outline" tone="neutral">Share</Button>
      <template #content="{ close }">
        <List>
          <ListItem v-for="target in targets" :key="target.label">
            <Button
              variant="ghost"
              tone="neutral"
              class="w-full justify-start"
              @click="((picked = target.label), close())"
            >
              <template #icon><component :is="target.icon" /></template>
              {{ target.label }}
            </Button>
          </ListItem>
        </List>
      </template>
    </Sheet>
    <Text v-if="picked" tone="muted" size="sm">Picked: {{ picked }}</Text>
  </Stack>
</template>
vue

Usage

import { Sheet } from '@hina-ui/vue'
ts

title is required and description is the line under it. The default slot is the trigger, content holds the body, and footer holds the actions. The content, footer, and custom-layout body slots receive a close method. Dragging the handle or title area downward closes the sheet once it travels far or fast enough; otherwise it snaps back. The close button is hidden by default when a handle is shown.

<script setup lang="ts">
  import { Button, Sheet, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="Filters" description="Only affects the current list.">
    <Button variant="outline" tone="neutral">Open filters</Button>
    <template #content>
      <Text>Put the filter controls here. Drag the handle at the top downward to close.</Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">Reset</Button>
      <Button @click="close">Apply</Button>
    </template>
  </Sheet>
</template>
vue

Examples

Title content

As with Dialog, the icon slot displays a decorative icon before the title, and the title slot replaces the title content, falling back to the title prop. The custom title keeps its <h2> semantics and accessible name association.

The example includes a Tag in the title.

<script setup lang="ts">
  import { Settings } from '@lucide/vue'
  import { Button, Inline, Sheet, Tag, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="Panel title">
    <Button variant="outline" tone="neutral">Title slots</Button>
    <template #icon><Settings /></template>
    <template #title>
      <Inline as="span" :wrap="false" class="inline-flex gap-2">
        Custom title
        <Tag size="sm" tone="neutral">Optional</Tag>
      </Inline>
    </template>
    <template #content>
      <Text>The icon is separate, and the title slot combines text with a tag.</Text>
    </template>
  </Sheet>
</template>
vue

Close button

closable defaults to true. Setting it to false only hides the header’s close button. Escape and clicking the scrim still close the panel; locked controls these dismissal behaviors. The built-in close button is not rendered with a hidden header or a body slot. Sheet keeps its default behavior of hiding the close button when the handle is shown. With :handle="false" and a visible header, closable controls the button; the title area remains draggable.

<script setup lang="ts">
  import { Button, Sheet, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="Hidden close button" :closable="false" :handle="false">
    <Button variant="outline" tone="neutral">Hidden close button</Button>
    <template #content>
      <Text>Press Escape, click the scrim or use the footer button to close.</Text>
    </template>
    <template #footer="{ close }">
      <Button @click="close">Close</Button>
    </template>
  </Sheet>
</template>
vue

Custom panel content

#body="{ close }" takes over the internal layout, replacing the default header, content, and footer. The component no longer adds content padding, region gaps, or a ScrollArea wrapper. The slot controls scrolling and bottom safe-area spacing. An empty slot still replaces the default layout.

In this mode, header, closable, and the icon, title, content, and footer slots do not participate in rendering. title is still required; it and the supplied description remain visually hidden. The scrim, focus trap, and locked remain active, and the slot’s close() can close the panel programmatically.

handle still controls the handle independently. It stays above the custom content by default, and only its region can start a drag. Setting :handle="false" removes the top drag region and its spacing. Custom body content does not become draggable.

The example uses CloseButton, ScrollArea, and Button to compose a flush header, a separate scroll area, and a fixed footer.

<script setup lang="ts">
  import { Button, CloseButton, Sheet, Inline, ScrollArea, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline>
    <Sheet
      v-for="handle in [true, false]"
      :key="String(handle)"
      :handle="handle"
      title="Custom layout"
      description="The title and description remain available to assistive technology."
    >
      <Button variant="outline" tone="neutral">
        {{ handle ? 'With handle' : 'Without handle' }}
      </Button>
      <template #body="{ close }">
        <Stack gap="none" class="h-[min(32rem,70dvh)] min-h-0">
          <Inline
            justify="between"
            :wrap="false"
            class="border-line bg-inset shrink-0 gap-3 border-b p-4"
          >
            <Stack gap="xs" class="min-w-0">
              <Text weight="medium">Custom layout</Text>
              <Text size="sm" tone="muted">
                The slot provides the header, scroll area, and footer.
              </Text>
            </Stack>
            <CloseButton @click="close" />
          </Inline>
          <ScrollArea class="min-h-0 flex-1">
            <Stack gap="none" class="divide-line divide-y px-4">
              <Text v-for="index in 30" :key="index" class="py-3">Item {{ index }}</Text>
            </Stack>
          </ScrollArea>
          <Inline
            justify="between"
            :wrap="false"
            class="border-line shrink-0 gap-3 border-t p-4 pb-[max(var(--hn-panel-p),env(safe-area-inset-bottom))]"
          >
            <Text size="sm" tone="muted">The footer stays visible</Text>
            <Button size="sm" @click="close">Done</Button>
          </Inline>
        </Stack>
      </template>
    </Sheet>
  </Inline>
</template>
vue

Long content

Content taller than the available height scrolls inside the content slot while the title and footer stay put; the sheet grows up to the viewport minus a margin at the top.

<script setup lang="ts">
  import { Button, Sheet, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="Terms of service" description="Read to the end before agreeing.">
    <Button variant="outline" tone="neutral">View terms</Button>
    <template #content>
      <Stack gap="md">
        <Text v-for="n in 24" :key="n">
          Clause {{ n }}: this clause exists to show scrolling inside the sheet; the title and
          footer stay put while the body scrolls in between.
        </Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button @click="close">Agree</Button>
    </template>
  </Sheet>
</template>
vue

Scroll viewport

Use the component ref’s viewport to access the actual scrollable element of its built-in ScrollArea. Read scrollTop, call scrollTo(), or pass it to scroll listeners and observers.

viewport has type HTMLElement | undefined. It is undefined before the content scroll area initializes, without a content slot, and after content unmounts. It remains available during exit and updates to a new element on reopening. Watch () => modal.value?.viewport to act when ready or attach listeners, and remove listeners in the watch cleanup callback.

With a body slot, the built-in scroll area is replaced and viewport is undefined. Reference a custom scroll area directly.

The example uses Button to scroll with viewport.scrollTo().

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Sheet, Stack, Text } from '@hina-ui/vue'

  const modal = ref<InstanceType<typeof Sheet>>()

  function scrollTo(position: 'start' | 'end') {
    const viewport = modal.value?.viewport
    if (!viewport) return
    viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
  }
</script>

<template>
  <Sheet
    ref="modal"
    title="Scroll viewport"
    description="The title and footer stay fixed while the body scrolls."
  >
    <Button variant="outline" tone="neutral">Open</Button>
    <template #content>
      <Stack gap="sm">
        <Text v-for="index in 30" :key="index">
          Line {{ index }} The title and footer stay fixed while this content scrolls.
        </Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="!modal?.viewport" @click="scrollTo('start')">
        Top
      </Button>
      <Button variant="soft" tone="neutral" :disabled="!modal?.viewport" @click="scrollTo('end')">
        Bottom
      </Button>
      <Button @click="close">Close</Button>
    </template>
  </Sheet>
</template>
vue

Controlled

open supports two-way binding. Without a default slot no trigger is rendered and the sheet can only be opened from outside.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Sheet, Text } from '@hina-ui/vue'

  const open = ref(false)
</script>

<template>
  <Inline gap="sm">
    <Button variant="outline" tone="neutral" @click="open = true">Open from outside</Button>
    <Sheet v-model:open="open" title="Saved" description="This article is now in your collection.">
      <template #content>
        <Text>A sheet without a trigger, opened by the button outside.</Text>
      </template>
      <template #footer="{ close }">
        <Button @click="close">Got it</Button>
      </template>
    </Sheet>
  </Inline>
</template>
vue

Locked

With locked, dragging, Esc and clicking the scrim no longer close the sheet, and the handle dims to show it is unavailable for now; closing through open still works. The sheet is locked in the same way while a Form inside it is submitting, whether or not locked is set; see Dialog for the usage.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Sheet, Text } from '@hina-ui/vue'

  const open = ref(false)
  const saving = ref(false)

  async function save() {
    saving.value = true
    await new Promise(resolve => setTimeout(resolve, 1500))
    saving.value = false
    open.value = false
  }
</script>

<template>
  <Sheet
    v-model:open="open"
    title="Save changes"
    description="The sheet stays put while saving."
    :locked="saving"
  >
    <Button variant="outline" tone="neutral">Open</Button>
    <template #content>
      <Text>
        For a second and a half after pressing Save, dragging, Esc and the scrim do nothing.
      </Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="saving" @click="close">Cancel</Button>
      <Button :loading="saving" @click="save">Save</Button>
    </template>
  </Sheet>
</template>
vue

Without the handle

handle set to false hides the handle. When the header is visible, a close button appears in its corner and the title area can still be dragged. Set closable=false to hide the button.

<script setup lang="ts">
  import { Button, Sheet, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet
    title="No handle"
    description="A close button takes its place; the title area can still be dragged."
    :handle="false"
  >
    <Button variant="outline" tone="neutral">Open</Button>
    <template #content>
      <Text>Try dragging the title downward.</Text>
    </template>
  </Sheet>
</template>
vue

Behavior

  • Overlays stack in opening order, with the latest above earlier overlays, regardless of component mount order. Closing preserves the full exit animation before removing the overlay.
  • The sheet slides in from the bottom edge, centered with a maximum width on wide screens and full width on narrow ones, leaving room for the device’s safe area at the bottom in the default layout. Custom body content controls its own safe-area spacing.
  • Dragging starts only on the handle and the title area, leaving the body to scrolling; on release the sheet keeps sliding out from where it was let go when it has travelled more than three tenths of its height or fast enough, and snaps back otherwise.
  • The page stops scrolling while it is open, focus is trapped inside, and focus returns to the trigger on close.
  • Esc and clicking the scrim close it; locked disables both along with dragging.
  • A press on the scrim or focus moving outside fires interactOutside first; calling preventDefault() keeps it open. Content inside an OverlayBranch does not count as outside.

Accessibility

  • The panel is role="dialog", with title and description linked through aria-labelledby and aria-describedby.
  • The handle is a visual hint hidden from assistive technology; the close button shown with a visible header and no handle carries a localized name.
  • Dragging is a shortcut for touch and mouse; keyboard users close it with Esc, and a footer button can call close.

API

Props

Prop
Type
Default
Description
title
string
—
Required. Title of the sheet
description
string
—
Line under the title
header
boolean
true
Show the header; keep the accessible name and description when hidden
handle
boolean
true
Show the handle; the close button only appears when the header is visible
closable
boolean
true
Show the close button in the header
locked
boolean
false
Whether the user is kept from closing it
open
boolean
—
Whether it is open, supports two-way binding
class
string
—
Classes appended to the panel

Slots

Slot
Payload
Description
default
—
The trigger; omit it to render none
icon
—
Decorative icon before the title
title
—
Title content; defaults to the title prop
body
{ close, submitting }
Custom internal layout; replaces the default header, content, and footer
content
{ close, submitting }
Content, scrolling when it is too tall
footer
{ close, submitting }
Actions along the bottom

close closes the sheet; submitting is true while a Form inside the sheet is submitting.

Event
Payload
Description
interactOutside
PointerDownOutsideEvent | FocusOutsideEvent
Fires on a press or focus outside; preventDefault() keeps it open

Exposed instance

Property
Type
Description
viewport
HTMLElement | undefined
Actual scrollable element of the built-in ScrollArea; available after initialization and cleared when content unmounts