MobTranslate styleguide

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

PartActual contract
Root / portal / backdropDialog modality and lifecycle; fixed dimming layer
PopupFixed card surface, 2px border, --shadow-xl, 24px padding, scrollable content
left / right20rem width, capped at 85vw; spans viewport height
top / bottomFull viewport width; max-height 85dvh; internal scroll
Title / description--text-lg weight 700 / --text-base muted
Class contractStyled base is mt-drawer-popup; side class adds geometry
MotionEdge slide using tokens; none under reduced motion
Close / applyExplicit 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 pointWeb choice and verification boundary
Standard versus modalThe 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 expansionNative 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 operationEvery 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 scrollingA 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 pointWeb choice and verification boundary
Placement and RTLUse 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 interactionKeep 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 navigationSide-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 keyboardThe 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 motionNative 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.

tsx
'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

css
.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.

tsx
'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 propInstalled contract
open / defaultOpenControlled boolean / initial uncontrolled boolean; default false.
onOpenChange(open, eventDetails); details.reason describes cause; details.cancel() can reject a change.
onOpenChangeCompleteCalled after open/close animations finish; distinct from a submitted task succeeding.
modalDialog/Drawer: true default; false or trap-focus alternatives. AlertDialog is always modal.
disablePointerDismissalDialog/Drawer: false default. AlertDialog does not expose this prop and prevents outside-pointer dismissal.
actionsRefImperative close/unmount lifecycle; do not hold invisible inert content indefinitely.
handle / triggerId / defaultTriggerIdInstalled primitive trigger association/payload mechanisms; imperative triggers need a meaningful final focus destination.
childrenParts 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 / propContract
TriggerInstalled button/render props, disabled, nativeButton, trigger payload/association; button ref. Use render={<Button type="button" />} to compose one control.
CloseInstalled button/render props and button ref; closes the surface, not an application request.
PortalMounts the surface outside local layout; preserve theme variables through its actual container. keepMounted retains the subtree, not an open state.
BackdropDiv/render props, className string or state callback, style and div ref; visible dimming belongs to modality.
PopupDiv/render props and div ref; initialFocus and finalFocus accept boolean, element ref or interaction-type callback.
TitleInstalled heading/render props and heading ref; supplies the visible dialog name.
DescriptionInstalled paragraph/render props and paragraph ref; supplies a concise purpose/message.
className / render / native propsStyled 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.