<script setup lang="ts">
import { SlidersHorizontal } from '@lucide/vue'
import { Button, Drawer, Input, Stack, Text } from '@hina-ui/vue'
</script>
<template>
<Drawer title="Filters" description="Only works matching these conditions are shown.">
<Button variant="outline" tone="neutral">
<template #icon>
<SlidersHorizontal />
</template>
Filters
</Button>
<template #content>
<Stack gap="sm">
<Stack gap="xs">
<Text size="sm">Keyword</Text>
<Input model-value="observatory" />
</Stack>
<Stack gap="xs">
<Text size="sm">Release year</Text>
<Input model-value="2024" />
</Stack>
<Stack gap="xs">
<Text size="sm">Producer</Text>
<Input model-value="ANIPLEX.EXE" />
</Stack>
</Stack>
</template>
<template #footer="{ close }">
<Button variant="soft" tone="neutral" @click="close">Reset</Button>
<Button @click="close">Apply</Button>
</template>
</Drawer>
</template>
Usage
import { Drawer } from '@hina-ui/vue'
title is required and description is the line under it. The default slot is the trigger, the content slot is the body, and the footer slot holds the actions. The content, footer, and custom-layout body slots receive a close method.
<script setup lang="ts">
import { Button, Drawer, Text } from '@hina-ui/vue'
</script>
<template>
<Drawer title="Notifications" description="Messages from the past seven days.">
<Button variant="outline" tone="neutral">Open notifications</Button>
<template #content>
<Text>
The message list goes here. The drawer opens flush against the edge of the screen and keeps
its square corners.
</Text>
</template>
</Drawer>
</template>
The drawer opens flush against the edge of the screen, fills the full height and keeps square corners. The scrim, the scroll lock and the focus trap are the same as in Dialog.
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, Drawer, Tag, Text } from '@hina-ui/vue'
</script>
<template>
<Drawer 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>
</Drawer>
</template>
Hidden header
Set :header="false" to hide the header, including its title, description, and close button; content and footer keep their existing layout. title is still required and remains available with the supplied description as visually hidden content. The icon and title slots are not rendered.
<script setup lang="ts">
import { Button, Drawer, Text } from '@hina-ui/vue'
</script>
<template>
<Drawer title="Hidden header" :header="false">
<Button variant="outline" tone="neutral">Hidden header</Button>
<template #content>
<Text>The title remains the accessible name; content and footer stay visible.</Text>
</template>
<template #footer="{ close }">
<Button @click="close">Close</Button>
</template>
</Drawer>
</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.
<script setup lang="ts">
import { Button, Drawer, Text } from '@hina-ui/vue'
</script>
<template>
<Drawer title="Hidden close button" :closable="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>
</Drawer>
</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.
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, Inline, Drawer, ScrollArea, Stack, Text } from '@hina-ui/vue'
</script>
<template>
<Drawer
title="Custom layout"
description="The title and description remain available to assistive technology."
>
<Button variant="outline" tone="neutral">Custom panel</Button>
<template #body="{ close }">
<Stack gap="none" class="min-h-0 flex-1">
<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>
</Drawer>
</template>
Side
side takes start or end, meaning the start and end edges of the writing direction. In English that is the left and right edge; in a right-to-left language they swap automatically.
<script setup lang="ts">
import { Button, Drawer, Inline, Text } from '@hina-ui/vue'
</script>
<template>
<Inline align="center" class="gap-6">
<Drawer title="From the start edge" side="start">
<Button variant="outline" tone="neutral">start</Button>
<template #content>
<Text>The left edge in English, the right edge in a right-to-left language.</Text>
</template>
</Drawer>
<Drawer title="From the end edge" side="end">
<Button variant="outline" tone="neutral">end</Button>
<template #content>
<Text>The default, which is the right edge in English.</Text>
</template>
</Drawer>
</Inline>
</template>
Sizes
size sets the width of the drawer: 288, 360 and 480 pixels. On a narrow screen the width never exceeds the viewport minus 48 pixels.
<script setup lang="ts">
import { Button, Drawer, Inline, Text } from '@hina-ui/vue'
</script>
<template>
<Inline align="center" class="gap-6">
<Drawer
v-for="size in ['sm', 'md', 'lg'] as const"
:key="size"
:size="size"
:title="`Size ${size}`"
>
<Button variant="outline" tone="neutral">{{ size }}</Button>
<template #content>
<Text>The width follows size, while the height always fills the screen.</Text>
</template>
</Drawer>
</Inline>
</template>
Long content
Content past the available height scrolls inside the content slot while the title and footer stay put.
<script setup lang="ts">
import { Button, Drawer, Stack, Text } from '@hina-ui/vue'
const items = Array.from(
{ length: 30 },
(_, i) =>
`Notification ${i + 1}: placeholder text, long enough that the body actually scrolls.`,
)
</script>
<template>
<Drawer
title="All notifications"
description="The title and footer stay put while the list scrolls."
>
<Button variant="outline" tone="neutral">See all</Button>
<template #content>
<Stack gap="sm">
<Text v-for="item in items" :key="item">{{ item }}</Text>
</Stack>
</template>
<template #footer="{ close }">
<Button variant="soft" tone="neutral" @click="close">Mark all as read</Button>
</template>
</Drawer>
</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, Drawer, Stack, Text } from '@hina-ui/vue'
const modal = ref<InstanceType<typeof Drawer>>()
function scrollTo(position: 'start' | 'end') {
const viewport = modal.value?.viewport
if (!viewport) return
viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
}
</script>
<template>
<Drawer
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>
</Drawer>
</template>
Controlled
open supports two-way binding. Leaving out the default slot renders no trigger, so the drawer can only be opened from outside.
Currently closed
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Drawer, Inline, Text } from '@hina-ui/vue'
const open = ref(false)
</script>
<template>
<Inline align="center">
<Button variant="outline" tone="neutral" @click="open = true">Open from outside</Button>
<Text tone="muted" size="sm">Currently {{ open ? 'open' : 'closed' }}</Text>
<Drawer
v-model:open="open"
title="A drawer without a trigger"
description="Outside state controls it."
>
<template #content>
<Text>Leaving out the default slot renders no trigger, so `open` is the only way in.</Text>
</template>
<template #footer="{ close }">
<Button @click="close">Close</Button>
</template>
</Drawer>
</Inline>
</template>
Locked
With locked, neither Escape nor a click on the scrim closes the drawer, and any visible close button is disabled. Closing through open still works. The drawer 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, Drawer, Text } from '@hina-ui/vue'
const open = ref(false)
const saving = ref(false)
function save() {
saving.value = true
setTimeout(() => {
saving.value = false
open.value = false
}, 2000)
}
</script>
<template>
<Drawer
v-model:open="open"
title="Edit tags"
description="Please keep this drawer open while it saves."
:locked="saving"
>
<Button variant="outline" tone="neutral">Edit tags</Button>
<template #content>
<Text>
{{
saving
? 'Saving. It closes on its own in two seconds.'
: 'Saving locks the drawer for two seconds.'
}}
</Text>
</template>
<template #footer="{ close }">
<Button variant="soft" tone="neutral" :disabled="saving" @click="close">Cancel</Button>
<Button :loading="saving" @click="save">Save</Button>
</template>
</Drawer>
</template>
Behaviour
- 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 page is locked from scrolling while the drawer is open, focus is trapped inside the panel, and it returns to the trigger on close.
- Escape or a click on the scrim closes the drawer;
lockeddisables both. - A press on the scrim or focus moving outside fires
interactOutsidefirst; callingpreventDefault()keeps it open. Content inside an OverlayBranch does not count as outside. - The default body uses ScrollArea; a
bodyslot controls its own scrolling.
Accessibility
- The panel is a
role="dialog", withtitleanddescriptionwired toaria-labelledbyandaria-describedby. - The title renders as an
<h2>. With a hidden header or abodyslot, thetitleprop provides a visually hidden heading. - The close button carries an accessible name taken from the current language.
API
Drawer
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Required. The drawer title |
description | string | — | The line under the title |
side | 'start' | 'end' | 'end' | Which edge it slides in from |
size | 'sm' | 'md' | 'lg' | 'md' | Width of the drawer |
header | boolean | true | Show the header; preserve the accessible name and description when hidden |
closable | boolean | true | Show the close button in the header |
locked | boolean | false | Whether the user is kept from closing |
open | boolean | — | Whether it is open; supports v-model |
class | string | — | Classes appended to the panel |
| 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 drawer; submitting is true while a Form inside the drawer 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 |