Skip to content

Drawer

Drawer is a slide-out panel for supplementary content and actions.

Import

vue
<script setup lang="ts">
import {
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerHeading,
  DrawerTrigger,
} from '@rysinal/heroui-vue'
</script>

Usage

Default

<template>
  <Drawer>
    <Button variant="secondary">Open Drawer</Button>
    <DrawerBackdrop>
      <DrawerContent placement="right">
        <DrawerDialog>
          <DrawerHeader>
            <DrawerHeading>Drawer Title</DrawerHeading>
          </DrawerHeader>
          <DrawerBody>
            <p>
              This is a right drawer built with the Vue Drawer component. It slides in from the
              edge of the screen with a smooth CSS transition.
            </p>
          </DrawerBody>
          <DrawerFooter>
            <Button data-drawer-close="true" variant="secondary">Cancel</Button>
            <Button data-drawer-close="true">Confirm</Button>
          </DrawerFooter>
        </DrawerDialog>
      </DrawerContent>
    </DrawerBackdrop>
  </Drawer>
</template>

<script setup lang="ts">
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHeader,
  DrawerHeading,
} from '@rysinal/heroui-vue'
</script>

Anatomy

vue
<template>
  <Drawer>
    <Button>Open Drawer</Button>
    <DrawerBackdrop>
      <DrawerContent>
        <DrawerDialog>
          <DrawerHandle />
          <DrawerCloseTrigger />
          <DrawerHeader>
            <DrawerHeading>Title</DrawerHeading>
          </DrawerHeader>
          <DrawerBody>Drawer content</DrawerBody>
          <DrawerFooter />
        </DrawerDialog>
      </DrawerContent>
    </DrawerBackdrop>
  </Drawer>
</template>

Placement

<template>
  <div class="flex flex-wrap gap-4">
    <Drawer v-for="placement in placements" :key="placement">
      <Button variant="secondary">{{ placementLabel(placement) }}</Button>
      <DrawerBackdrop>
        <DrawerContent :placement="placement">
          <DrawerDialog>
            <DrawerCloseTrigger />
            <DrawerHandle v-if="placement === 'bottom'" />
            <DrawerHeader>
              <DrawerHeading>{{ placementLabel(placement) }} Drawer</DrawerHeading>
            </DrawerHeader>
            <DrawerBody>
              <p>
                This drawer slides in from the <strong>{{ placement }}</strong> edge of the screen.
              </p>
            </DrawerBody>
            <DrawerFooter>
              <Button data-drawer-close="true" variant="secondary">Cancel</Button>
              <Button data-drawer-close="true">Done</Button>
            </DrawerFooter>
            <DrawerHandle v-if="placement === 'top'" />
          </DrawerDialog>
        </DrawerContent>
      </DrawerBackdrop>
    </Drawer>
  </div>
</template>

<script setup lang="ts">
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerHeading,
  type DrawerPlacement,
} from '@rysinal/heroui-vue'

const placements: DrawerPlacement[] = ['bottom', 'top', 'left', 'right']
const placementLabel = (placement: DrawerPlacement) =>
  placement.charAt(0).toUpperCase() + placement.slice(1)
</script>

Backdrop Variants

<template>
  <div class="flex flex-wrap gap-4">
    <Drawer v-for="variant in variants" :key="variant">
      <Button variant="secondary">{{ variantLabel(variant) }}</Button>
      <DrawerBackdrop :variant="variant">
        <DrawerContent>
          <DrawerDialog>
            <DrawerHandle />
            <DrawerCloseTrigger />
            <DrawerHeader>
              <DrawerHeading>Backdrop: {{ variantLabel(variant) }}</DrawerHeading>
            </DrawerHeader>
            <DrawerBody>
              <p>
                This drawer uses the <code>{{ variant }}</code> backdrop variant.
              </p>
            </DrawerBody>
            <DrawerFooter>
              <Button class="w-full" data-drawer-close="true">Close</Button>
            </DrawerFooter>
          </DrawerDialog>
        </DrawerContent>
      </DrawerBackdrop>
    </Drawer>
  </div>
</template>

<script setup lang="ts">
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerHeading,
  type DrawerBackdropVariant,
} from '@rysinal/heroui-vue'

const variants: DrawerBackdropVariant[] = ['opaque', 'blur', 'transparent']
const variantLabel = (variant: DrawerBackdropVariant) =>
  variant.charAt(0).toUpperCase() + variant.slice(1)
</script>

Non-Dismissable

<template>
  <Drawer>
    <Button variant="secondary">Important Action</Button>
    <DrawerBackdrop :is-dismissable="false">
      <DrawerContent>
        <DrawerDialog>
          <DrawerHeader>
            <DrawerHeading>Confirm Action</DrawerHeading>
          </DrawerHeader>
          <DrawerBody>
            <p>
              This drawer cannot be dismissed by clicking outside or dragging. You must use one of
              the buttons below.
            </p>
          </DrawerBody>
          <DrawerFooter>
            <Button data-drawer-close="true" variant="secondary">Cancel</Button>
            <Button data-drawer-close="true">Confirm</Button>
          </DrawerFooter>
        </DrawerDialog>
      </DrawerContent>
    </DrawerBackdrop>
  </Drawer>
</template>

<script setup lang="ts">
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHeader,
  DrawerHeading,
} from '@rysinal/heroui-vue'
</script>

Scrollable Content

<template>
  <Drawer>
    <Button variant="secondary">Terms & Conditions</Button>
    <DrawerBackdrop>
      <DrawerContent>
        <DrawerDialog>
          <DrawerHandle />
          <DrawerCloseTrigger />
          <DrawerHeader>
            <DrawerHeading>Terms & Conditions</DrawerHeading>
          </DrawerHeader>
          <DrawerBody>
            <p v-for="index in 20" :key="index" class="mb-3">
              Paragraph {{ index }}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam
              pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet hendrerit risus,
              sed porttitor quam.
            </p>
          </DrawerBody>
          <DrawerFooter>
            <Button data-drawer-close="true" variant="secondary">Decline</Button>
            <Button data-drawer-close="true">Accept</Button>
          </DrawerFooter>
        </DrawerDialog>
      </DrawerContent>
    </DrawerBackdrop>
  </Drawer>
</template>

<script setup lang="ts">
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerHeading,
} from '@rysinal/heroui-vue'
</script>

Controlled State

With ref()

Control the drawer using Vue's ref for simple state management.

Status: closed

With small state helpers

Use a tiny local helper object for convenient methods like open(), close(), and toggle().

Status: closed

<template>
  <div class="flex max-w-md flex-col gap-8">
    <div class="flex flex-col gap-3">
      <h3 class="m-0 text-lg font-semibold text-foreground">With ref()</h3>
      <p class="m-0 text-sm leading-relaxed text-pretty text-muted">
        Control the drawer using Vue's <code class="text-foreground">ref</code> for simple state
        management.
      </p>
      <div class="flex flex-col items-start gap-3 rounded-2xl bg-surface p-4 shadow-sm">
        <div class="flex w-full items-center justify-between">
          <p class="m-0 text-xs text-muted">
            Status:
            <span class="font-mono font-medium text-foreground">
              {{ isOpen ? 'open' : 'closed' }}
            </span>
          </p>
        </div>
        <div class="flex gap-2">
          <Button size="sm" variant="secondary" @click="isOpen = true">Open Drawer</Button>
          <Button size="sm" variant="tertiary" @click="isOpen = !isOpen">Toggle</Button>
        </div>
      </div>

      <DrawerBackdrop :is-open="isOpen" @open-change="isOpen = $event">
        <DrawerContent placement="right">
          <DrawerDialog>
            <DrawerCloseTrigger />
            <DrawerHeader>
              <DrawerHeading>Controlled with ref()</DrawerHeading>
            </DrawerHeader>
            <DrawerBody>
              <p>
                This drawer is controlled by a Vue <code>ref</code>. Pass <code>isOpen</code> and
                listen for <code>openChange</code> to manage state externally.
              </p>
            </DrawerBody>
            <DrawerFooter>
              <Button data-drawer-close="true" variant="secondary">Close</Button>
            </DrawerFooter>
          </DrawerDialog>
        </DrawerContent>
      </DrawerBackdrop>
    </div>

    <div class="flex flex-col gap-3">
      <h3 class="m-0 text-lg font-semibold text-foreground">With small state helpers</h3>
      <p class="m-0 text-sm leading-relaxed text-pretty text-muted">
        Use a tiny local helper object for convenient methods like <code>open()</code>,
        <code>close()</code>, and <code>toggle()</code>.
      </p>
      <div class="flex flex-col items-start gap-3 rounded-2xl bg-surface p-4 shadow-sm">
        <div class="flex w-full items-center justify-between">
          <p class="m-0 text-xs text-muted">
            Status:
            <span class="font-mono font-medium text-foreground">
              {{ overlay.isOpen.value ? 'open' : 'closed' }}
            </span>
          </p>
        </div>
        <div class="flex gap-2">
          <Button size="sm" variant="secondary" @click="overlay.open">Open Drawer</Button>
          <Button size="sm" variant="tertiary" @click="overlay.toggle">Toggle</Button>
        </div>
      </div>

      <DrawerBackdrop :is-open="overlay.isOpen.value" @open-change="overlay.setOpen">
        <DrawerContent placement="right">
          <DrawerDialog>
            <DrawerCloseTrigger />
            <DrawerHeader>
              <DrawerHeading>Controlled with helpers</DrawerHeading>
            </DrawerHeader>
            <DrawerBody>
              <p>
                The helper object provides dedicated methods for common operations without manually
                creating callbacks every time.
              </p>
            </DrawerBody>
            <DrawerFooter>
              <Button data-drawer-close="true" variant="secondary">Close</Button>
            </DrawerFooter>
          </DrawerDialog>
        </DrawerContent>
      </DrawerBackdrop>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import {
  Button,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHeader,
  DrawerHeading,
} from '@rysinal/heroui-vue'

const isOpen = ref(false)
const helperOpen = ref(false)
const overlay = {
  close: () => {
    helperOpen.value = false
  },
  isOpen: computed(() => helperOpen.value),
  open: () => {
    helperOpen.value = true
  },
  setOpen: (value: boolean) => {
    helperOpen.value = value
  },
  toggle: () => {
    helperOpen.value = !helperOpen.value
  },
}
</script>

With Form

<template>
  <Drawer>
    <Button variant="secondary">Edit Profile</Button>
    <DrawerBackdrop>
      <DrawerContent placement="right">
        <DrawerDialog>
          <DrawerCloseTrigger />
          <DrawerHeader>
            <DrawerHeading>Edit Profile</DrawerHeading>
          </DrawerHeader>
          <DrawerBody>
            <form class="flex flex-col gap-4" @submit.prevent>
              <TextField
                v-model="form.name"
                class="w-full"
                label="Name"
                name="name"
                placeholder="Enter your name"
                variant="secondary"
              />
              <TextField
                v-model="form.email"
                class="w-full"
                label="Email"
                name="email"
                placeholder="Enter your email"
                type="email"
                variant="secondary"
              />
              <TextField
                v-model="form.bio"
                class="w-full"
                label="Bio"
                name="bio"
                placeholder="Tell us about yourself"
                variant="secondary"
              />
            </form>
          </DrawerBody>
          <DrawerFooter>
            <Button data-drawer-close="true" variant="secondary">Cancel</Button>
            <Button data-drawer-close="true">Save Changes</Button>
          </DrawerFooter>
        </DrawerDialog>
      </DrawerContent>
    </DrawerBackdrop>
  </Drawer>
</template>

<script setup lang="ts">
import { reactive } from 'vue'
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerFooter,
  DrawerHeader,
  DrawerHeading,
  TextField,
} from '@rysinal/heroui-vue'

const form = reactive({
  bio: '',
  email: '',
  name: '',
})
</script>

<template>
  <Drawer>
    <Button variant="secondary">
      <svg
        aria-hidden="true"
        class="size-5"
        fill="none"
        stroke="currentColor"
        stroke-linecap="round"
        stroke-linejoin="round"
        stroke-width="2"
        viewBox="0 0 24 24"
      >
        <path d="M4 6h16" />
        <path d="M4 12h16" />
        <path d="M4 18h16" />
      </svg>
      Menu
    </Button>
    <DrawerBackdrop>
      <DrawerContent placement="left">
        <DrawerDialog>
          <DrawerCloseTrigger />
          <DrawerHeader>
            <DrawerHeading>Navigation</DrawerHeading>
          </DrawerHeader>
          <DrawerBody>
            <nav aria-label="Main navigation" class="flex flex-col gap-1">
              <button
                v-for="item in navItems"
                :key="item.label"
                class="flex w-full items-center justify-start gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-default"
                type="button"
              >
                <svg
                  aria-hidden="true"
                  class="size-5 shrink-0 text-muted"
                  fill="none"
                  stroke="currentColor"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  viewBox="0 0 24 24"
                >
                  <component
                    :is="segment.element"
                    v-for="segment in item.icon"
                    :key="segment.key"
                    v-bind="segment.attrs"
                  />
                </svg>
                {{ item.label }}
              </button>
            </nav>
          </DrawerBody>
        </DrawerDialog>
      </DrawerContent>
    </DrawerBackdrop>
  </Drawer>
</template>

<script setup lang="ts">
import {
  Button,
  Drawer,
  DrawerBackdrop,
  DrawerBody,
  DrawerCloseTrigger,
  DrawerContent,
  DrawerDialog,
  DrawerHeader,
  DrawerHeading,
} from '@rysinal/heroui-vue'

type IconSegment = {
  attrs: Record<string, string | number>
  element: 'circle' | 'path' | 'rect'
  key: string
}

const navItems = [
  {
    icon: [
      { attrs: { d: 'M3 10.5 12 3l9 7.5' }, element: 'path', key: 'roof' },
      { attrs: { d: 'M5 10v10h14V10' }, element: 'path', key: 'frame' },
      { attrs: { d: 'M9 20v-6h6v6' }, element: 'path', key: 'door' },
    ],
    label: 'Home',
  },
  {
    icon: [
      { attrs: { cx: 11, cy: 11, r: 7 }, element: 'circle', key: 'lens' },
      { attrs: { d: 'm20 20-4.2-4.2' }, element: 'path', key: 'handle' },
    ],
    label: 'Search',
  },
  {
    icon: [
      { attrs: { d: 'M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9' }, element: 'path', key: 'bell' },
      { attrs: { d: 'M13.7 21a2 2 0 0 1-3.4 0' }, element: 'path', key: 'clapper' },
    ],
    label: 'Notifications',
  },
  {
    icon: [
      { attrs: { height: 16, rx: 2, width: 20, x: 2, y: 4 }, element: 'rect', key: 'box' },
      { attrs: { d: 'm22 7-8.5 5.5a3 3 0 0 1-3 0L2 7' }, element: 'path', key: 'fold' },
    ],
    label: 'Messages',
  },
  {
    icon: [
      { attrs: { cx: 12, cy: 7, r: 4 }, element: 'circle', key: 'avatar' },
      { attrs: { d: 'M4 21v-2a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v2' }, element: 'path', key: 'body' },
    ],
    label: 'Profile',
  },
  {
    icon: [
      { attrs: { cx: 12, cy: 12, r: 3 }, element: 'circle', key: 'center' },
      {
        attrs: {
          d: 'M19.4 15a1.8 1.8 0 0 0 .4 2l.1.1a2.1 2.1 0 0 1-3 3l-.1-.1a1.8 1.8 0 0 0-2-.4 1.8 1.8 0 0 0-1 1.7V21a2.1 2.1 0 0 1-4.2 0v-.1a1.8 1.8 0 0 0-1-1.7 1.8 1.8 0 0 0-2 .4l-.1.1a2.1 2.1 0 0 1-3-3l.1-.1a1.8 1.8 0 0 0 .4-2 1.8 1.8 0 0 0-1.7-1H3a2.1 2.1 0 0 1 0-4.2h.1a1.8 1.8 0 0 0 1.7-1 1.8 1.8 0 0 0-.4-2l-.1-.1a2.1 2.1 0 0 1 3-3l.1.1a1.8 1.8 0 0 0 2 .4 1.8 1.8 0 0 0 1-1.7V3a2.1 2.1 0 0 1 4.2 0v.1a1.8 1.8 0 0 0 1 1.7 1.8 1.8 0 0 0 2-.4l.1-.1a2.1 2.1 0 0 1 3 3l-.1.1a1.8 1.8 0 0 0-.4 2 1.8 1.8 0 0 0 1.7 1h.1a2.1 2.1 0 0 1 0 4.2h-.1a1.8 1.8 0 0 0-1.7 1z',
        },
        element: 'path',
        key: 'gear',
      },
    ],
    label: 'Settings',
  },
] satisfies Array<{ icon: IconSegment[]; label: string }>
</script>

API

Drawer Props

PropTypeDefaultDescription
modelValuebooleanundefinedControlled open state.
isOpenbooleanundefinedAlternative controlled open state.
defaultOpenbooleanfalseInitial uncontrolled open state.

DrawerBackdrop Props

PropTypeDefaultDescription
variant'transparent' | 'opaque' | 'blur''opaque'Backdrop treatment.
isDismissablebooleantrueClose when pressing the backdrop or dragging past the dismiss threshold.
isKeyboardDismissDisabledbooleanfalseDisable Escape dismissal.
modelValuebooleanundefinedStandalone controlled open state.
isOpenbooleanundefinedStandalone controlled open state alias.
portalContainerHTMLElement | string'body'Custom teleport target.
unstablePortalContainerHTMLElementundefinedReact-compatible custom portal target alias.

DrawerContent Props

PropTypeDefaultDescription
placement'bottom' | 'top' | 'left' | 'right''bottom'Drawer edge placement.

DrawerHeading Props

PropTypeDefaultDescription
asstringundefinedRendered heading element override.
level1 | 2 | 3 | 4 | 5 | 62Heading level semantics.

Released under the Apache-2.0 License.