Skip to content
ShineUI
Install

Drawer

Detail without leaving.

<sui-drawer>OverlaysStable

About

Slide-in panel for record detail without leaving the list. It slides in from any edge over the navy scrim and the list stays visible behind it, or it fills the page. The host sets open; Escape, the scrim and the corner X ask to close and can be refused. Reach for it when the list gives the record its context.

Usage

<sui-drawer heading="INV-1043" subtitle="Silver Hills Church. Open." width="300"> <div style="font-size:13.5px">Issued Aug 8, 2026<br>Due Sep 7, 2026<br>Balance $6,420.00</div> <sui-button slot="footer" variant="accent" label="Receive payment"></sui-button> </sui-drawer> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-drawer"); el.addEventListener("suiClose", (e) => console.log(e.detail)); </script>
import { SuiDrawer } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return ( <SuiDrawer heading="INV-1043" subtitle="Silver Hills Church. Open." width="300" onSuiClose={(e) => console.log(e.detail)}> <div style="font-size:13.5px">Issued Aug 8, 2026<br>Due Sep 7, 2026<br>Balance $6,420.00</div> <sui-button slot="footer" variant="accent" label="Receive payment"></sui-button> </SuiDrawer> ); }
<script setup> import { SuiDrawer } from "@shineui/vue"; import "@shineui/core/shineui.css"; const onChange = (e) => console.log(e.detail); </script> <template> <SuiDrawer heading="INV-1043" subtitle="Silver Hills Church. Open." width="300" @sui-close="onChange"><div style="font-size:13.5px">Issued Aug 8, 2026<br>Due Sep 7, 2026<br>Balance $6,420.00</div> <sui-button slot="footer" variant="accent" label="Receive payment"></sui-button></SuiDrawer> </template>
import { Component } from "@angular/core"; import { SuiDrawer } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiDrawer], template: `<sui-drawer heading="INV-1043" subtitle="Silver Hills Church. Open." width="300" [(open)]="open" (suiClose)="onChange($event)"><div style="font-size:13.5px">Issued Aug 8, 2026<br>Due Sep 7, 2026<br>Balance $6,420.00</div> <sui-button slot="footer" variant="accent" label="Receive payment"></sui-button></sui-drawer>`, }) export class Example { open = false; onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The panel is role="dialog", named from heading, the page behind it is inert, and the list stays put behind the scrim. Focus opens on the first field in the body, on the body itself when it scrolls, or on the panel, and returns to the opener on close.

Issued Aug 8, 2026
Due Sep 7, 2026
Balance $6,420.00
<sui-button variant="outline" label="Open INV-1043"></sui-button> <sui-drawer heading="INV-1043" subtitle="Silver Hills Church. Open." width="300"> <div style="font-size:13.5px">Issued Aug 8, 2026<br>Due Sep 7, 2026<br>Balance $6,420.00</div> <sui-button slot="footer" variant="accent" label="Receive payment"></sui-button> </sui-drawer>
// setup(root): properties and events setup(root) { const drawer = root.querySelector('sui-drawer'); root.querySelector('sui-button').addEventListener('click', () => { drawer.open = true; }); }

Initial focus

initial-focus is a selector resolved inside the drawer for the control that opens with focus, the same rule as sui-dialog; on a sui-button it lands on the inner button. Here it points at the note rather than the first field. A miss falls back to the first field, the scrolling body or the panel.

<sui-button id="open-focus-drawer" variant="outline" label="Edit INV-1044"></sui-button> <sui-drawer id="focus-drawer" heading="INV-1044" subtitle="Riverbend Dental Group" width="320" initial-focus="#drawer-note"> <div style="display:grid;gap:12px"> <sui-input-text label="Reference" value="PO 4471"></sui-input-text> <sui-textarea id="drawer-note" label="Internal note" value="Promised payment Friday"></sui-textarea> </div> <sui-button slot="footer" variant="brand" label="Save"></sui-button> </sui-drawer>
// setup(root): properties and events setup(root) { const drawer = root.querySelector('#focus-drawer'); root.querySelector('#open-focus-drawer').addEventListener('click', () => { drawer.open = true; }); }

Four edges

position picks the edge. Right and left are sized by width, top and bottom by height, and both are capped at 90% of the viewport.

Date range, customer and status live here.
<div style="display:flex;gap:10px;flex-wrap:wrap"> <sui-button data-edge="right" variant="outline" label="Right"></sui-button> <sui-button data-edge="left" variant="outline" label="Left"></sui-button> <sui-button data-edge="bottom" variant="outline" label="Bottom"></sui-button> <sui-button data-edge="top" variant="outline" label="Top"></sui-button> </div> <sui-drawer id="edges" heading="Filters" subtitle="Aging report" width="320" height="240"> <div style="font-size:13.5px">Date range, customer and status live here.</div> <sui-button slot="footer" variant="brand" label="Apply"></sui-button> </sui-drawer>
// setup(root): properties and events setup(root) { const drawer = root.querySelector('#edges'); root.querySelectorAll('[data-edge]').forEach((b) => b.addEventListener('click', () => { drawer.position = b.dataset.edge; drawer.open = true; })); }

Full screen

position="full" covers the viewport with the same header, footer and close control, and fades in instead of sliding. width and height are ignored.

Current $41,200.00
1 to 30 days $12,860.00
31 to 60 days $6,420.00
Over 60 days $18,300.00
<sui-button id="open-full-drawer" variant="outline" label="Open aging report"></sui-button> <sui-drawer id="full-drawer" position="full" heading="Aging report" subtitle="All customers, as of today"> <div style="font-size:13.5px">Current $41,200.00<br>1 to 30 days $12,860.00<br>31 to 60 days $6,420.00<br>Over 60 days $18,300.00</div> <sui-button id="close-full-drawer" slot="footer" variant="brand" label="Close report"></sui-button> </sui-drawer>
// setup(root): properties and events setup(root) { const drawer = root.querySelector('#full-drawer'); root.querySelector('#open-full-drawer').addEventListener('click', () => { drawer.open = true; }); root.querySelector('#close-full-drawer').addEventListener('click', () => { drawer.open = false; }); }

Composed header

The header slot replaces heading and subtitle and supplies the accessible name, so the title can carry a status tag.

INV-1045
Halyard Freight
Issued Jul 2, 2026
Balance $18,300.00
<sui-button id="open-hdr-drawer" variant="outline" label="Open INV-1045"></sui-button> <sui-drawer id="hdr-drawer" width="320"> <span slot="header" style="display:inline-flex;align-items:center;gap:8px"> <span style="font-size:17px;font-weight:730;letter-spacing:var(--tracking-tight)">INV-1045</span> <sui-tag label="Overdue" severity="critical"></sui-tag> </span> <div style="font-size:13.5px">Halyard Freight<br>Issued Jul 2, 2026<br>Balance $18,300.00</div> <sui-button slot="footer" variant="accent" label="Send reminder"></sui-button> </sui-drawer>
// setup(root): properties and events setup(root) { const drawer = root.querySelector('#hdr-drawer'); root.querySelector('#open-hdr-drawer').addEventListener('click', () => { drawer.open = true; }); }

Cancelable close

suiClose fires before the drawer closes and says why: escape, scrim or close-button. Here a stray scrim click is refused with preventDefault while the note is unsaved; Escape and the X still close.

No close requested yet.
<sui-button id="open-dirty-drawer" variant="outline" label="Open INV-1044"></sui-button> <div id="drawer-log" style="margin-top:10px;font-size:12px;color:var(--ink-muted)">No close requested yet.</div> <sui-drawer id="dirty-drawer" heading="INV-1044" subtitle="Riverbend Dental Group. Open." width="320"> <sui-input-text label="Internal note" value="Promised payment Friday"></sui-input-text> <sui-button slot="footer" variant="accent" label="Receive payment"></sui-button> </sui-drawer>
// setup(root): properties and events setup(root) { const drawer = root.querySelector('#dirty-drawer'); const log = root.querySelector('#drawer-log'); root.querySelector('#open-dirty-drawer').addEventListener('click', () => { drawer.open = true; }); drawer.addEventListener('suiClose', (e) => { if (e.detail === 'scrim') { e.preventDefault(); log.textContent = 'Refused a scrim dismissal. The note is unsaved.'; return; } log.textContent = 'Closed by ' + e.detail + '.'; }); }

Keyboard

KeyAction
Tab Shift+TabCycles through the controls in the panel and never leaves it. A panel opened from the drawer, a select list or a popover, counts as inside; focus that lands behind the scrim by any other route is brought back.
EscapeAsks to close with reason escape. Only the topmost open surface answers it: a popover, confirm popup or select list open inside the drawer takes it first and the drawer stays.
Enter SpaceOn the corner X, asks to close with reason close-button.
ArrowDown ArrowUp PageDown PageUpScroll the body when it overflows and holds focus, which is where a prose drawer opens.

API

<sui-drawer>

Properties

PropertyAttributeTypeDefaultDescription
headingheadingstring''Drawer title (the reference title prop; renamed for the native attribute). Supplies the accessible name unless a header slot is given.
heightheightnumber300Panel height in px for bottom and top (capped at 90% of the viewport).
initialFocusinitial-focusstringstring | undefinedA selector for the control that takes focus when the drawer opens, resolved inside the drawer: #memo, or .save for a sui-button, whose inner button then takes it. Without it focus lands on the first field in the body, else on the body itself when it scrolls, so the arrow and page keys scroll the drawer and not the page behind it, else on the panel; never on the corner X. The same rule as sui-dialog. A selector that matches nothing, or a disabled control, falls through to the same order.
openopenbooleanfalseShows or hides the drawer. The host sets it; on a close request the element clears it itself unless the suiClose listener calls preventDefault(), so plain HTML works. When the drawer closes, a popup still open inside it, a select's list or a popover, closes with it and asks through its own suiClose with reason outside; a popup that has focus takes Escape first and the drawer stays. The element never opens itself, so there is no suiOpen; watch open. While open, and while it fades out, the drawer paints in the browser's top layer, above the rest of the page, so a transformed, clipping or stacked ancestor cannot trap or bury it. It stays in place in the element, so a listener on an ancestor, a <form> around the element and inherited styles reach the slotted content as they would anywhere else. Everything outside the drawer is inert and the page does not scroll until it closes; a node the page adds outside it while it is open goes inert too. The panel is role="dialog" with no aria-modal: the inert outside is what makes it modal to assistive technology, and with aria-modal VoiceOver would drop the toast region outside it. Toasts keep announcing, and Tab goes on from the drawer's last control into the toast stack and back. An element the page marks data-sui-keep-live (another library's dropdown appended to the body, a chat widget) stays usable with everything in it: it never goes inert, focus may move into it and back, and Tab reaches it the same way. The drawer paints in the top layer, so a marked panel that should show above it must paint there too, as a popover does. On a viewport too short for the panel with its header and footer pinned (a phone on its side, a page zoomed to 400%), the whole panel scrolls instead of only its body, so the actions stay in reach.
positionposition"bottom" | "full" | "left" | "right" | "top"'right'Edge the panel slides in from. full covers the whole viewport instead, with the same header and close control and no rounded corners; width and height are then ignored.
returnFocusreturn-focusstringstring | undefinedA selector for the control that takes focus when the drawer closes, resolved in the document (or the shadow root) the element sits in when it closes, so it can name a row the drawer's own action created. Without it focus returns to the control that had it when the drawer opened; when that control has left the page or can no longer take focus, it goes to the nearest control in its place. The same rule as sui-dialog. A selector that matches nothing, or a disabled control, falls through to the same order.
subtitlesubtitlestringstring | undefinedSecondary line under the title (customer, status).
widthwidthnumber380Panel width in px for right and left (capped at 90% of the viewport).

Events

EventDetailReactVueDescription
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the drawer closes itself; detail is 'escape' | 'scrim' | 'close-button'. Cancelable: preventDefault() keeps it open. Refusing one reason, such as scrim while a form is dirty, leaves the others working. The corner X is always there: unlike sui-dialog there is no hide-close. It does not bubble, so a listener on the drawer never hears a select or a popover closing inside it.

Slots

SlotDescription
(default)Drawer body.
footerAction buttons, right-aligned on the sunken footer.
headerReplaces heading / subtitle when the title needs a status tag, an icon or a document number chip. It supplies the accessible name.

Types

SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also