<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>
Usage
import { Sheet } from '@hina-ui/vue'
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>
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>
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>
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>
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>
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>
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>
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>
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>
Hidden header
As with Dialog, set :header="false" to hide the header, including its title, description, and close button. title is still required; it and description remain available to assistive technology as visually hidden content. The icon and title slots are not rendered.
handle controls the handle independently. With the header hidden, the remaining handle still supports drag-to-dismiss. Also setting :handle="false" removes the top drag region, so content starts at the regular padding. Esc, the scrim, and the slots’ close method can still close the sheet; locked keeps its existing behavior.
The example calls close from a footer Button.
<script setup lang="ts">
import { Button, Inline, Sheet, Text } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Sheet
v-for="handle in [true, false]"
:key="String(handle)"
title="Hidden header"
description="The title and description remain available to assistive technology."
:header="false"
:handle="handle"
>
<Button variant="outline" tone="neutral">
{{ handle ? 'Keep handle' : 'Hide entire top' }}
</Button>
<template #content>
<Text>Content starts below the handle or at the regular panel padding.</Text>
</template>
<template #footer="{ close }">
<Button variant="soft" tone="neutral" @click="close">Close</Button>
</template>
</Sheet>
</Inline>
</template>
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
bodycontent 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;
lockeddisables both along with dragging. - A press on the scrim or focus moving outside fires
interactOutsidefirst; callingpreventDefault()keeps it open. Content inside an OverlayBranch does not count as outside.
Accessibility
- The panel is
role="dialog", withtitleanddescriptionlinked througharia-labelledbyandaria-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 |