Edge-docked overlay with portal, backdrop, and focus management.
Introduction
Drawer docks a panel to a viewport edge. It portals content over a backdrop, locks page scroll by default, and manages focus while open.
Prefer Drawer for side panels, filters, and settings that stay attached to an edge. Prefer Modal for centered dialogs.
Use the framework selector in the site header to switch between React and Vue.
Import
import { Drawer } from "@bridge-ui/vue/Components/Drawer";import { Drawer } from "@bridge-ui/react/Components/Drawer";Basic usage
Pair a trigger with a controlled Drawer. Place content such as a Card inside the panel.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open drawer</Button>
<Drawer v-model="open">
<Card class="h-full" title="Confirm action">
Are you sure you want to continue?
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
export default function DrawerBasic() {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open drawer</Button>
<Drawer show={open} onShowChange={setOpen}>
<Card className="h-full" title="Confirm action">
Are you sure you want to continue?
</Card>
</Drawer>
</div>
);
}
Sizes
Use the size prop to control panel width for left/right placement and height for top/bottom. Available sizes: xs, sm, md (default), lg, xl, and full.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
type Size = "lg" | "md" | "sm" | "xl" | "xs" | "full";
const size = ref<null | Size>(null);
const open = ref(false);
const openSize = (value: Size) => {
size.value = value;
open.value = true;
};
const onShowChange = (show: boolean) => {
open.value = show;
if (!show) size.value = null;
};
</script>
<template>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" v-on:click="openSize('xs')">
xs
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('sm')">
sm
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('md')">
md
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('lg')">
lg
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('xl')">
xl
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('full')">
full
</Button>
<Drawer
v-if="size"
:size="size"
placement="right"
:model-value="open"
v-on:show-change="onShowChange"
>
<Card class="h-full" :title="`size="${size}"`">
Size controls width for left/right and height for top/bottom.
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
type Size = "lg" | "md" | "sm" | "xl" | "xs" | "full";
export default function DrawerSizes() {
const [size, setSize] = useState<null | Size>(null);
return (
<div className="flex flex-wrap gap-2">
{(["lg", "md", "sm", "xl", "xs", "full"] as const).map((value) => (
<Button
size="sm"
key={value}
variant="outline"
onClick={() => setSize(value)}
>
{value}
</Button>
))}
<Drawer
show={!!size}
placement="right"
size={size ?? "md"}
onShowChange={(show) => !show && setSize(null)}
>
<Card className="h-full" title={`size="${size}"`}>
Size controls width for left/right and height for top/bottom.
</Card>
</Drawer>
</div>
);
}
Persistent
When persistent is true, Escape and backdrop clicks do not close the drawer. Provide an explicit close action inside the content.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open persistent drawer</Button>
<Drawer persistent v-model="open">
<Card
class="h-full"
title="Persistent drawer"
:classes="{ body: 'min-h-0 flex-1' }"
>
Clicking the backdrop or pressing Escape has no effect.
<template #footer>
<div class="flex justify-end">
<Button size="sm" v-on:click="open = false">Close</Button>
</div>
</template>
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
export default function DrawerPersistent() {
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open persistent drawer</Button>
<Drawer persistent show={open} onShowChange={setOpen}>
<Card
className="h-full"
title="Persistent drawer"
classes={{ body: "min-h-0 flex-1" }}
slots={{
footer: (
<div className="flex justify-end">
<Button size="sm" onClick={close}>
Close
</Button>
</div>
),
}}
>
Clicking the backdrop or pressing Escape has no effect.
</Card>
</Drawer>
</div>
);
}
Placements
Use placement to dock the panel to left (default), right, top, or bottom. size controls width for left/right and height for top/bottom.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
type Placement = "top" | "left" | "right" | "bottom";
const placement = ref<null | Placement>(null);
const open = ref(false);
const openPlacement = (edge: Placement) => {
placement.value = edge;
open.value = true;
};
const onShowChange = (show: boolean) => {
open.value = show;
if (!show) placement.value = null;
};
</script>
<template>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" v-on:click="openPlacement('left')">
left
</Button>
<Button size="sm" variant="outline" v-on:click="openPlacement('right')">
right
</Button>
<Button size="sm" variant="outline" v-on:click="openPlacement('top')">
top
</Button>
<Button size="sm" variant="outline" v-on:click="openPlacement('bottom')">
bottom
</Button>
<Drawer
v-if="placement"
:model-value="open"
:placement="placement"
v-on:show-change="onShowChange"
>
<Card class="h-full" :title="`${placement} drawer`">
Docks to the {{ placement }} edge. Size controls width for left/right
and height for top/bottom.
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
type Placement = "top" | "left" | "right" | "bottom";
export default function DrawerPlacements() {
const [placement, setPlacement] = useState<null | Placement>(null);
return (
<div className="flex flex-wrap gap-2">
{(["top", "left", "right", "bottom"] as const).map((edge) => (
<Button
size="sm"
key={edge}
variant="outline"
onClick={() => setPlacement(edge)}
>
{edge}
</Button>
))}
<Drawer
show={!!placement}
placement={placement ?? "left"}
onShowChange={(show) => !show && setPlacement(null)}
>
<Card className="h-full" title={`${placement} drawer`}>
Docks to the {placement} edge. Size controls width for left/right and
height for top/bottom.
</Card>
</Drawer>
</div>
);
}
Transitions and blur
Set transition to slide (default), fade, or none. slide animates from the placement edge; fade fades both layers; none opens instantly. Combine with blur on the backdrop for depth.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
type Transition = "fade" | "none" | "slide";
const transition = ref<null | Transition>(null);
const open = ref(false);
const openTransition = (name: Transition) => {
transition.value = name;
open.value = true;
};
const onShowChange = (show: boolean) => {
open.value = show;
if (!show) transition.value = null;
};
</script>
<template>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" v-on:click="openTransition('slide')">
slide
</Button>
<Button size="sm" variant="outline" v-on:click="openTransition('fade')">
fade
</Button>
<Button size="sm" variant="outline" v-on:click="openTransition('none')">
none
</Button>
<Drawer
blur="md"
v-if="transition"
placement="right"
:model-value="open"
:transition="transition"
v-on:show-change="onShowChange"
>
<Card class="h-full" :title="`transition="${transition}"`">
Enter/leave animation for overlay and panel. Combined with
blur="md".
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
type Transition = "fade" | "none" | "slide";
export default function DrawerTransitions() {
const [transition, setTransition] = useState<null | Transition>(null);
return (
<div className="flex flex-wrap gap-2">
{(["fade", "none", "slide"] as const).map((name) => (
<Button
size="sm"
key={name}
variant="outline"
onClick={() => setTransition(name)}
>
{name}
</Button>
))}
<Drawer
blur="md"
placement="right"
show={!!transition}
transition={transition ?? "slide"}
onShowChange={(show) => !show && setTransition(null)}
>
<Card className="h-full" title={`transition="${transition}"`}>
Enter/leave animation for overlay and panel. Combined with
blur="md".
</Card>
</Drawer>
</div>
);
}
Nested drawers
Place a second Drawer inside the first. Each layer gets its own overlay and a higher z-index. Escape closes only the topmost drawer.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
const outerOpen = ref(false);
const innerOpen = ref(false);
const closeOuter = (open: boolean) => {
outerOpen.value = open;
if (!open) innerOpen.value = false;
};
</script>
<template>
<div class="w-full">
<Button v-on:click="outerOpen = true">Open nested drawers</Button>
<Drawer
size="lg"
placement="left"
:model-value="outerOpen"
v-on:show-change="closeOuter"
>
<Card
class="h-full"
title="Outer drawer"
:classes="{ body: 'min-h-0 flex-1' }"
>
<div class="flex flex-col gap-4">
<p>Open a second drawer on top while the first stays mounted.</p>
<Button size="sm" v-on:click="innerOpen = true">Open inner</Button>
</div>
</Card>
<Drawer
size="sm"
placement="right"
:model-value="innerOpen"
v-on:show-change="(show) => (innerOpen = show)"
>
<Card class="h-full" title="Inner drawer">
Nested layer. Escape closes only the topmost drawer.
</Card>
</Drawer>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
export default function DrawerNested() {
const [outerOpen, setOuterOpen] = useState(false);
const [innerOpen, setInnerOpen] = useState(false);
const closeOuter = (open: boolean) => {
setOuterOpen(open);
if (!open) setInnerOpen(false);
};
return (
<div className="w-full">
<Button onClick={() => setOuterOpen(true)}>Open nested drawers</Button>
<Drawer
size="lg"
show={outerOpen}
placement="left"
onShowChange={closeOuter}
>
<Card
className="h-full"
title="Outer drawer"
classes={{ body: "min-h-0 flex-1" }}
>
<div className="flex flex-col gap-4">
<p>Open a second drawer on top while the first stays mounted.</p>
<Button size="sm" onClick={() => setInnerOpen(true)}>
Open inner
</Button>
</div>
</Card>
<Drawer
size="sm"
show={innerOpen}
placement="right"
onShowChange={setInnerOpen}
>
<Card className="h-full" title="Inner drawer">
Nested layer. Escape closes only the topmost drawer.
</Card>
</Drawer>
</Drawer>
</div>
);
}
Card composition
Combine Drawer with Card for structured panels. Use Card slots for title, body, and footer actions. Additional Drawer props such as blur, size, and transition customize the overlay experience.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Edit profile</Button>
<Drawer blur="md" size="lg" v-model="open" placement="right">
<Card
borderless
shadow="lg"
rounded="none"
class="h-full"
padding="large"
variant="elevated"
title="Edit profile"
:classes="{ body: 'min-h-0 flex-1' }"
>
Large right-docked drawer with Card title, body, and footer slots.
<template #footer>
<div class="flex justify-end gap-2">
<Button
size="sm"
color="error"
variant="flat"
v-on:click="open = false"
>
Discard
</Button>
<Button size="sm" color="primary" v-on:click="open = false">
Save
</Button>
</div>
</template>
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
export default function DrawerCardComposition() {
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Edit profile</Button>
<Drawer
blur="md"
size="lg"
show={open}
placement="right"
onShowChange={setOpen}
>
<Card
borderless
shadow="lg"
rounded="none"
padding="large"
variant="elevated"
className="h-full"
title="Edit profile"
classes={{ body: "min-h-0 flex-1" }}
slots={{
footer: (
<div className="flex justify-end gap-2">
<Button size="sm" color="error" variant="flat" onClick={close}>
Discard
</Button>
<Button size="sm" color="primary" onClick={close}>
Save
</Button>
</div>
),
}}
>
Large right-docked drawer with Card title, body, and footer slots.
</Card>
</Drawer>
</div>
);
}
Scroll lock
While open, Drawer locks body scroll and adds padding-right on document.body so page content does not shift when the scrollbar disappears. Fixed or sticky UI outside the document flow is not covered by that padding. Bridge UI sets --bridge-scrollbar-compensation on :root with the scrollbar width for the duration of the lock (and clears it when the last locking layer closes):
.my-fixed-header {
padding-inline-end: var(--bridge-scrollbar-compensation, 0px);
}Set disableScrollLock if the page should keep scrolling behind the overlay.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Drawer } from "@bridge-ui/vue/Components/Drawer";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open without scroll lock</Button>
<Drawer v-model="open" disable-scroll-lock>
<Card class="h-full" title="disableScrollLock">
The page behind this overlay can still scroll while the drawer is open.
</Card>
</Drawer>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Drawer } from "@bridge-ui/react/Components/Drawer";
export default function DrawerDisableScrollLock() {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open without scroll lock</Button>
<Drawer show={open} disableScrollLock onShowChange={setOpen}>
<Card className="h-full" title="disableScrollLock">
The page behind this overlay can still scroll while the drawer is
open.
</Card>
</Drawer>
</div>
);
}
Related components
Accessibility
- Content under an active drawer is inert—users cannot interact with the page behind the overlay.
- Focus is trapped inside the drawer by default (disableEnforceFocus disables this).
- Focus returns to the trigger on close unless disableRestoreFocus is set.
- Escape closes the drawer when closeOnEscape is true and persistent is false.
- Pass ariaLabel or ariaLabelledBy for an accessible dialog name.
Anatomy
<div> <!-- Drawer root (portaled) -->
<div> <!-- overlay / backdrop -->
<div> <!-- panel -->
<!-- children: Card, form, or custom content -->
</div>
</div>API
| Prop | Type | Default | Description |
|---|---|---|---|
| v-model | boolean | — | Two-way binding for overlay visibility. |
| @close | — | — | Fired when the user dismisses (Escape or click-away). Not fired when the parent sets v-model={false} directly. |
| @show-change | (show: boolean) => void | — | Equivalent to onShowChange when you prefer events over a callback prop. |
| Prop | Type | Default | Description |
|---|---|---|---|
| show | boolean | — | Whether the overlay is visible. Use with onShowChange for controlled state. |
| onShowChange | (show: boolean) => void | — | Called when visibility changes. |
| onClose | () => void | — | Fired when the user dismisses (Escape or click-away). Not fired when the parent sets show={false} directly. |
| Prop | Type | Default | Description |
|---|---|---|---|
| placement | DrawerPlacement | "left" | Which edge the panel docks to. |
| size | DrawerSize | "md" | Panel size along the placement axis. |
| transition | DrawerTransition | "slide" | Enter/leave animation for overlay and panel. |
| blur | DrawerBlur | "none" | Backdrop blur on the overlay. |
| persistent | boolean | false | When true, Escape and overlay clicks do not close the drawer. |
| closeOnEscape | boolean | true | Whether the drawer closes on Escape. |
| closeOnOverlay | boolean | true | Whether the drawer closes on overlay click. |
| hideBackdrop | boolean | false | When true, the backdrop overlay is not rendered. |
| scroll | DrawerScroll | "paper" | Where scroll happens: page (body) or panel (paper). |
| autoFocus | boolean | false | Focus the first focusable element on open. |
| disableEnforceFocus | boolean | false | When true, focus is not trapped inside the drawer. |
| disableRestoreFocus | boolean | false | When true, focus is not restored on close. |
| disableScrollLock | boolean | false | When true, body scroll is not locked while open. |
| keepMounted | boolean | false | When true, the drawer stays mounted after closing (hidden). |
| teleportTo | string | false | "body" | Portal target. Pass false to render in place. |
| ariaLabel | string | — | Accessible name for the dialog. |
| ariaLabelledBy | string | — | Id of the element that labels the dialog. |
| classes | DrawerClasses | — | Class overrides for drawer parts. |
| customProps | DrawerCustomProps | — | Props forwarded to each drawer part. |