Drawer
Show contextual work from an edge with deliberate modality, applied state and a usable return to the page.
Drawers and sheets
Use an edge surface for contextual filters, a short supporting task or temporary navigation. Choose Dialog for a centered decision and a dedicated page for an extensive workflow. The bottom variant provides a modal web-sheet pattern; it has no drag handle, snap points or gesture physics.
Drawer uses the installed Dialog primitive, so its default behavior is modal. A persistent navigation rail or standard sheet needs an explicit non-modal layout, keyboard route and responsive adaptation; changing side alone does not implement those patterns. Left/right are physical sides and do not automatically swap for RTL.
Anatomy, placement and specifications
| Part | Actual contract |
|---|---|
| Root / portal / backdrop | Dialog modality and lifecycle; fixed dimming layer |
| Popup | Fixed card surface, 2px border, --shadow-xl, 24px padding, scrollable content |
| left / right | 20rem width, capped at 85vw; spans viewport height |
| top / bottom | Full viewport width; max-height 85dvh; internal scroll |
| Title / description | --text-lg weight 700 / --text-base muted |
| Class contract | Styled base is mt-drawer-popup; side class adds geometry |
| Motion | Edge slide using tokens; none under reduced motion |
| Close / apply | Explicit caller-composed actions; no automatic data filter or cancellation |
Material bottom sheets: supplementary content and real height choices
The reference distinguishes standard bottom sheets that coexist with primary content from modal sheets that block it. Only the container is required; optional content may include lists, dividers, media and actions. A handle must be a real resize/cycle control when interactive, not decorative grabber styling that promises nonexistent gestures.
Native 28dp upper corners,640dp maximum width,72dp upper margin (56dp margins on wider windows),22dp handle padding and 48dp handle target differ from this Drawer’s actual geometry. The source calls modal bottom sheets mobile-only but also discusses medium/expanded adaptation; keep the intended task/window relationship explicit instead of a universal browser breakpoint rule.
| Reference point | Web choice and verification boundary |
|---|---|
| Standard versus modal | The staged-filter example uses the installed Drawer with scrim, trapped focus and staged Apply/Cancel. The separate standard supporting panel keeps primary content interactive and actually filters local records. Their page-memory lifecycles are separate, and no automatic native modal-to-standard handoff is claimed. |
| Preview and expansion | Native sheets may preview, expand or reach full screen, with an initial modal visibility rule and internal scroll for remaining content. The standard lab offers an explicit height-cycle button for three real bounded panel sizes in document flow. These are local panel footprints, not native snap points, a full-screen window or simulated drag physics. |
| Non-drag operation | Every drag/height change needs a single-pointer/keyboard alternative. The lab’s external Cycle height and explicit Close panel remain available independently of the scrollable content; it does not implement a touch drag handle. Native predictive-back detachment/scaling remains a platform behavior. |
| Dismissal and scrolling | A modal surface needs the installed Escape/outside/close policy plus meaningful final focus. A standard panel closes explicitly without making main content inert. Long panel content scrolls in its named region; an auxiliary fixed surface in a real product also needs keyboard/safe-area/content-clearance tests. |
Material side sheets: trailing placement and standard coexistence
Standard side sheets supplement primary content, commonly in wider layouts; modal side sheets are preferred when compact space makes coexistence impractical. Native anatomy includes a title, optional internal Back, optional dividers/actions and a close affordance. Its usage calls Close optional/recommended while accessibility requires it: these web examples provide an explicit close path.
Native maximum 400dp width,24dp inset,12dp top gaps,72dp bottom action region and 16dp detached margins/corners are references. A fixed modal=false popup does not by itself create a standard pane that shrinks the primary region. The standard lab implements that flow relationship at sufficient available space and moves below content when narrow.
| Reference point | Web choice and verification boundary |
|---|---|
| Placement and RTL | Use the logical trailing edge for supporting tasks so primary navigation retains its leading identity. In RTL that generally means left. Drawer side itself is physical; the standard pane uses actual DOM direction/layout, while consumers selecting a physical modal side must choose it deliberately. |
| State and primary interaction | Keep draft/applied filter ownership explicit. The standard panel can stay open while the primary draft is edited; Apply changes actual visible IDs, and closing/reopening retains the applied result without pretending to save remotely. A modal sheet blocks background interaction instead. |
| Scroll and internal navigation | Side-sheet content may scroll vertically independently; do not imply horizontal scrolling through a narrow sheet. Internal Back must describe an actual retained subview, not merely reuse a decorative arrow. This lab has one filter view and no false Back destination. |
| Role and keyboard | The source’s Dialog role recommendation applies to its native sheet model. The standard web panel is a named nonmodal region with ordinary Tab order, a reading heading and real close/focus restoration; the shared modal Drawer remains a dialog. Do not claim trapping or aria-modal for the standard layout. |
| Adaptation and motion | Native examples can transition between modal and standard and offer predictive-back physics. This composition instead reflows a stable standard region from trailing pane to below-content panel, retaining values/focusable controls. Modal alternatives remain separately demonstrated; no platform animation or window-service API is installed. |
Working standard panel and primary-content coexistence
Edit the primary draft while the supporting filters remain open. Apply changes actual visible record IDs; close/reopen distinguishes applied from discarded draft filters. Cycle height changes the actual scrollable footprint between 16, 24 and 32rem. The named panel is nonmodal and reflows to the logical trailing side at 48rem of local available space, or below content when narrow. Copy both checked source files.
Standard supporting filters
This in-flow panel keeps the primary draft and records usable. It appears below content when narrow, or on the logical trailing side when 48rem of local space fits. Its three explicit heights are local panel footprints, not native snap physics or full-screen windows.
Local panel height: 16rem. Height controls stay outside the scrolling panel.
- Local reading notes
- Local comparison notes
- Local recovery notes
Showing 3 of 3 local records; all records. Primary draft: Retained primary draft. No remote or durable write occurred.
'use client';
import {useLayoutEffect,useRef,useState} from 'react';
import {Button,Input} from '@mobtranslate/ui';
import './standard-support-sheet.css';
const records=[{id:'reading',label:'Local reading notes',bookmarked:true},{id:'comparison',label:'Local comparison notes',bookmarked:false},{id:'recovery',label:'Local recovery notes',bookmarked:true}];
export function StandardSupportSheetExample(){
const [open,setOpen]=useState(false);
const [draft,setDraft]=useState(false);
const [applied,setApplied]=useState(false);
const [text,setText]=useState('Retained primary draft');
const [rtl,setRtl]=useState(false);
const [height,setHeight]=useState(0);
const opener=useRef<HTMLButtonElement>(null);
const heading=useRef<HTMLHeadingElement>(null);
const visible=records.filter(record=>!applied||record.bookmarked);
useLayoutEffect(()=>{if(open){heading.current?.focus();heading.current?.scrollIntoView({block:'nearest'});}},[open]);
function close(){opener.current?.focus();setOpen(false);}
return <section data-standard-sheet-lab className="standard-sheet-lab space-y-4" dir={rtl?'rtl':'ltr'} aria-labelledby="standard-sheet-title">
<h3 dir="auto" id="standard-sheet-title" className="font-semibold">Standard supporting filters</h3>
<p dir="auto">This in-flow panel keeps the primary draft and records usable. It appears below content when narrow, or on the logical trailing side when 48rem of local space fits. Its three explicit heights are local panel footprints, not native snap physics or full-screen windows.</p>
<div className="standard-sheet-actions">
<Button ref={opener} type="button" variant="outline" aria-expanded={open} aria-controls={open?'standard-support-panel':undefined} onClick={()=>{if(!open)setDraft(applied);setOpen(true);if(open){heading.current?.focus();heading.current?.scrollIntoView({block:'nearest'});}}}>Open filters</Button>
<Button type="button" variant="outline" disabled={!open} aria-controls={open?'standard-support-panel':undefined} onClick={()=>setHeight(value=>(value+1)%3)}>Cycle height</Button>
<Button type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(value=>!value)}>Use RTL panel</Button>
</div>
<p dir="auto" data-standard-sheet-size>Local panel height: {16+height*8}rem. Height controls stay outside the scrolling panel.</p>
<div data-standard-sheet-shell className="standard-sheet-shell" data-open={open||undefined}>
<div data-standard-sheet-primary className="standard-sheet-primary space-y-4">
<label htmlFor="standard-sheet-draft" className="block">Primary draft</label><Input dir="auto" id="standard-sheet-draft" value={text} onChange={event=>setText(event.target.value)} />
<ul data-standard-sheet-records className="guidance-list">{visible.map(record=><li key={record.id} data-standard-record={record.id}>{record.label}</li>)}</ul>
</div>
{open&&<section id="standard-support-panel" data-standard-support-panel className="standard-support-panel space-y-4" style={{blockSize:`${16+height*8}rem`}} role="region" aria-labelledby="standard-support-heading" tabIndex={0}>
<div className="standard-sheet-actions"><h4 dir="auto" id="standard-support-heading" ref={heading} tabIndex={-1} className="font-semibold">Supporting filters</h4><Button type="button" variant="outline" onClick={close}>Close panel</Button></div>
<p dir="auto">Draft changes wait for Apply. Close discards uncommitted filter edits; the applied result and primary draft remain.</p>
<label className="standard-sheet-check"><input data-standard-sheet-filter type="checkbox" checked={draft} onChange={event=>setDraft(event.target.checked)} />Bookmarked only</label>
<Button type="button" onClick={()=>setApplied(draft)}>Apply filters</Button>
{Array.from({length:8},(_,index)=><p key={index}>Supporting context {index+1}. This English-only local content makes independent panel scrolling observable. Primary content remains interactive.</p>)}
</section>}
</div>
<p dir="auto" role="status" data-standard-sheet-status>Showing {visible.length} of {records.length} local records; {applied?'bookmarked only':'all records'}. Primary draft: <bdi>{text||'(empty)'}</bdi>. No remote or durable write occurred.</p>
</section>;
}
Standard panel styles and layout ownership
.standard-sheet-lab { container-type:inline-size; min-inline-size:0; }
.standard-sheet-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; }
.standard-sheet-lab .mt-btn { padding-inline:12px; min-height:44px; }
.standard-sheet-shell { display:flex; flex-direction:column; gap:16px; min-inline-size:0; }
.standard-sheet-primary { min-inline-size:0; }
.standard-support-panel { min-inline-size:0; max-inline-size:100%; overflow-y:auto; overflow-x:hidden; padding:16px; border:2px solid var(--color-muted-foreground); border-radius:var(--radius-lg); background:var(--color-muted); color:var(--color-foreground); }
.standard-support-panel p { overflow-wrap:anywhere; }
.standard-support-panel:focus-visible, .standard-support-panel h4:focus-visible { outline:2px solid var(--color-ring); outline-offset:2px; }
.standard-sheet-check { display:flex; align-items:center; gap:8px; min-block-size:44px; }
.standard-sheet-check input { inline-size:24px; block-size:24px; flex-shrink:0; }
@container(min-width:48rem) { .standard-sheet-shell[data-open] { display:grid; grid-template-columns:minmax(0,1fr) minmax(14rem,20rem); align-items:start; } }
@media(forced-colors:active) { .standard-support-panel { border-color:CanvasText; } }
Working staged filters and scrolling
Applied local filter: all examples.
'use client';
import { useRef, useState } from 'react';
import { Button, Drawer, DrawerBackdrop, DrawerClose, DrawerDescription, DrawerPopup, DrawerPortal, DrawerTitle, DrawerTrigger, type DrawerSide } from '@mobtranslate/ui';
export function DrawerContainmentExample() {
const [side, setSide] = useState<DrawerSide>('right');
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(false);
const [applied, setApplied] = useState(false);
const [long, setLong] = useState(false);
const title = useRef<HTMLHeadingElement>(null);
return <div data-drawer-lab className="space-y-4">
<label>Physical drawer side <select className="mt-input" value={side} onChange={event => setSide(event.target.value as DrawerSide)}>{['left','right','top','bottom'].map(item => <option key={item}>{item}</option>)}</select></label>
<label className="flex gap-3 min-h-11 items-center"><input type="checkbox" checked={long} onChange={event => setLong(event.target.checked)} />Include long content to test scrolling</label>
<Drawer open={open} onOpenChange={next => { setOpen(next); if (next) setDraft(applied); }}>
<DrawerTrigger render={<Button type="button" />}>Open local filters</DrawerTrigger>
<DrawerPortal><DrawerBackdrop /><DrawerPopup side={side} initialFocus={title} className={state => state.open ? 'local-drawer-open' : ''}>
<DrawerTitle ref={title} tabIndex={-1}>Local collection filters</DrawerTitle><DrawerDescription>Apply a local preference or cancel to keep the current selection.</DrawerDescription>
<label className="flex gap-3 min-h-11 items-center my-4"><input type="checkbox" checked={draft} onChange={event => setDraft(event.target.checked)} />Show locally bookmarked examples</label>
{long && <div className="space-y-4">{Array.from({length:16}, (_, index) => <p key={index}>Local context paragraph {index + 1}. This English-only placeholder tests viewport scrolling, not language data or source authority.</p>)}</div>}
<div className="flex flex-wrap gap-3 mt-4"><Button type="button" onClick={() => { setApplied(draft); setOpen(false); }}>Apply local filters</Button><DrawerClose render={<Button type="button" variant="outline" />}>Cancel local filters</DrawerClose></div>
</DrawerPopup></DrawerPortal>
</Drawer>
<p role="status">Applied local filter: {applied ? 'bookmarked examples' : 'all examples'}.</p>
</div>;
}
Root API
| Root prop | Installed contract |
|---|---|
| open / defaultOpen | Controlled boolean / initial uncontrolled boolean; default false. |
| onOpenChange | (open, eventDetails); details.reason describes cause; details.cancel() can reject a change. |
| onOpenChangeComplete | Called after open/close animations finish; distinct from a submitted task succeeding. |
| modal | Dialog/Drawer: true default; false or trap-focus alternatives. AlertDialog is always modal. |
| disablePointerDismissal | Dialog/Drawer: false default. AlertDialog does not expose this prop and prevents outside-pointer dismissal. |
| actionsRef | Imperative close/unmount lifecycle; do not hold invisible inert content indefinitely. |
| handle / triggerId / defaultTriggerId | Installed primitive trigger association/payload mechanisms; imperative triggers need a meaningful final focus destination. |
| children | Parts or installed payload render function. Root does not render its own DOM node. |
Parts API
Exports: Drawer, DrawerTrigger, DrawerClose, DrawerPortal, DrawerBackdrop, DrawerPopup, DrawerTitle, DrawerDescription, DrawerSide and DrawerPopupProps. DrawerPopup adds side=left|right|top|bottom, right default. No built-in Header, Body, Footer, snap or drag API is exported.
| Part / prop | Contract |
|---|---|
| Trigger | Installed button/render props, disabled, nativeButton, trigger payload/association; button ref. Use render={<Button type="button" />} to compose one control. |
| Close | Installed button/render props and button ref; closes the surface, not an application request. |
| Portal | Mounts the surface outside local layout; preserve theme variables through its actual container. keepMounted retains the subtree, not an open state. |
| Backdrop | Div/render props, className string or state callback, style and div ref; visible dimming belongs to modality. |
| Popup | Div/render props and div ref; initialFocus and finalFocus accept boolean, element ref or interaction-type callback. |
| Title | Installed heading/render props and heading ref; supplies the visible dialog name. |
| Description | Installed paragraph/render props and paragraph ref; supplies a concise purpose/message. |
| className / render / native props | Styled parts retain primitive state callbacks. Custom render must preserve forwarded props, semantics and refs. |
Focus, keyboard and dismissal
- Choose initial focus deliberately: a labelled field for a small edit, a focusable heading for long structured content, or the least destructive action for a consequential choice.
- Modal Tab/Shift+Tab remain inside; Escape closes when application policy permits. Always include a visible named close or cancel action.
- Return to the invoking control, or a meaningful enabled alternative when that control is removed or disabled by the result.
- Do not make long semantic content one flattened description. Use a concise description and retain headings, lists and paragraphs for normal reading.
- A pending operation can prevent accidental dismissal only when a real cancellation/recovery path remains. Closing the surface is not cancelling a network request.
- Non-modal use needs an explicit workflow and keyboard route between the surface and page. Do not claim modal blocking for modal=false or pointer blocking for trap-focus. Styled popup wrappers expose aria-modal only for fully modal roots; omit a dimming backdrop for non-modal/focus-only layouts.
State, gestures and failures
- Keep draft filter values separate from applied values. Opening copies the applied state; cancel/Escape/outside dismissal discard the draft; Apply commits the actual local result.
- For real requests, separately expose loading/error/retry and avoid claiming that a changed filter has returned data before the result exists.
- Do not require dragging to close. If gestures are added by a consumer, retain a named button and single-pointer/keyboard alternatives.
- Long content must scroll inside the bounded popup and leave Apply/Cancel reachable. Test virtual keyboards in the actual target browser.
- A base class mismatch leaves the surface unpositioned. An edge class alone is insufficient to supply the surface, z-index and scroll behavior.
- Do not use a modal drawer as permanent desktop navigation; modal background blocking must match the intended workflow.
Verification and release scope
- Verify the actual title, description relationships, focus destination, keyboard operation and task result in the consuming application.
- Exercise narrow and short viewports, both themes, long labels, text spacing, reduced motion and forced colors. Reach the last action by scrolling and keyboard.
- For overlays, check background interaction, scroll locking and each permitted dismissal path. A role or aria-modal attribute alone does not prove modal behavior.
- Use a named screen reader and browser for spoken output. Local examples and automated DOM checks do not prove announcements or a persisted backend result.
- Retain source/review/permission evidence. A container or confirmation cannot grant authority or establish that a request succeeded.