Adaptive overlays for desktop & mobile
Same task, different devices: choose between Dialog, Drawer, Sheet and Popover, and let Sheet become a side panel on wide screens and a bottom sheet within thumb reach on phones.
Choose an overlay
Start from the task, then think about the device. The more an overlay blocks, the more the task has to justify the interruption.
| Overlay | Desktop | Phone | Use for |
|---|---|---|---|
| Dialog | A modal box in the center of the screen. | Still centered; only for short content. | Immediate decisions: confirm, delete, fill in 1–3 fields. modalType="alert" for destructive actions. |
| Drawer | A left / right side panel (overlay) or a panel inside the layout (inline). | Easily covers the whole screen; the close button in the top corner is hard to reach. | Navigation and secondary panels that stay on one side, especially InlineDrawer on wide screens. |
| Sheet (Labs) | A start / end side panel, like Drawer. | A draggable bottom sheet with several snap points; swipe down to close. | Secondary tasks that need a moderate amount of space on any device: filters, record details, sharing, picking options. |
| Popover | A small non-modal surface anchored to a button. | Anchored to a small button, easily hidden by the on-screen keyboard or the screen edge. | Short information or choices tied to a specific spot. On phones, consider switching to a Sheet. |
- The user needs to stop and decide → Dialog. Don't put an important decision in a sheet that can be swiped away.
- Content that supports the page (filters, details) and may be used on a phone → Sheet: one declaration, two forms.
- A small choice tied to a button → Popover on desktop; on narrow screens switch to a bottom sheet with
useMediaQuery. - The app's main navigation → Nav / InlineDrawer, not a temporary overlay.
import { useMediaQuery, Popover, PopoverTrigger, PopoverSurface, Sheet, SheetTrigger, SheetContent } from "@/components/ui";
// Share menu: a Popover anchored to the button on desktop, a bottom sheet within thumb reach on phones.
function ShareAction() {
const isDesktop = useMediaQuery("(min-width: 768px)");
const trigger = <Button icon={<Share20Regular />}>Share</Button>;
if (isDesktop) {
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverSurface><ShareOptions /></PopoverSurface>
</Popover>
);
}
return (
<Sheet mode="bottom" snapPoints={[0.4]}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent>
<SheetHeader><SheetTitle>Share</SheetTitle></SheetHeader>
<SheetBody><ShareOptions /></SheetBody>
</SheetContent>
</Sheet>
);
}Sheet: one API, two forms
Sheet picks its form from the viewport width (useMediaQuery). Content, focus and state are kept when the user rotates the device or resizes the window.
<Sheet
side="end" // desktop: panel on the inline-end edge (left in RTL)
size="medium" // 592px
breakpoint="3xl" // < 48rem (768px) → bottom sheet
snapPoints={[0.45, 0.9]}
onSnapPointChange={(snap) => analytics.track("sheet_snap", { snap })}
>
<SheetTrigger asChild><Button>Order details</Button></SheetTrigger>
<SheetContent>
<SheetHeader action={<SheetClose asChild><Button appearance="subtle" icon={<Dismiss24Regular />} aria-label="Close" /></SheetClose>}>
<SheetTitle>Order #2026-1020</SheetTitle>
<SheetDescription>3 items · delivery by 18:00</SheetDescription>
</SheetHeader>
<SheetBody>{/* long content: only scrolls when the sheet is at its highest snap point */}</SheetBody>
<SheetFooter>{/* always stays at the bottom of the screen, even at the 45% snap point */}</SheetFooter>
</SheetContent>
</Sheet>| Prop · Type | Default | Description |
|---|---|---|
| mode"auto" | "side" | "bottom" | Default: "auto" | auto follows the breakpoint; side / bottom forces one form. |
| breakpoint"lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | number | Default: "3xl" | Minimum viewport width for the panel form: lg 32rem · xl 36rem · 2xl 42rem · 3xl 48rem · 4xl 56rem · 5xl 64rem. A px number is deprecated. |
| side / size"start" | "end" / "small" | "medium" | "large" | Default: "end" / "small" | Panel form: the edge (flips in RTL) and the width, 320 / 592 / 940px. |
| snapPointsnumber[] | Default: [0.5, 0.9] | Bottom sheet snap heights (as a fraction of the viewport height). |
| modal / dismissibleboolean | Default: true / true | Overlay + focus trap; allow closing with Esc / an outside click / a swipe. |
Bottom sheet ergonomics
On a phone held in one hand, the bottom third of the screen is the easiest area for the thumb to reach. A bottom sheet puts content and actions in that area.
| Factor | Behavior in Yugen | Guidance |
|---|---|---|
| Thumb zone | SheetFooter always stays at the bottom of the screen, even at a low snap point. | Put the primary action in the footer, not in the header. |
| Snap points | On release, the sheet moves to the nearest snap point; snap points are fractions of the viewport height (dvh). | 2–3 snap points: a quick look (0.25–0.5) and details (≈ 0.9, leaving a strip of the page visible above so users know they are still on the same page). |
| Fast swipe | Velocity > 0.45 px/ms → jumps to the next snap point in the swipe direction. | No need to drag far; one flick is enough. |
| Swipe down to close | Drag below half of the lowest snap point, or flick down from the lowest snap point; the overlay fades as you drag. | Turn it off with dismissible={false} when a choice is required. |
| Scrolling content | Content only scrolls at the highest snap point; at lower ones, dragging anywhere moves the sheet. Dragging down while at the top of the content moves the whole sheet. | Open long content at a low snap point first and let the user drag it up. |
| No-drag areas | Inputs, textareas, selects and elements with data-sheet-no-drag don't start a drag; horizontal swipes are left to carousels / sliders. | Add data-sheet-no-drag to maps, canvases and drawing areas. |
In desktop browsers, dragging works with the mouse (pointer events) when Sheet is in bottom form; the mouse wheel and two-finger touchpad gestures don't move the sheet. That is why the default form on wide screens is a side panel, not a bottom sheet.
Keyboard & screen readers
Every gesture has an equivalent that does not require dragging (WCAG 2.5.1 Pointer Gestures, 2.5.7 Dragging Movements).
| Key / situation | Result |
|---|---|
| Esc | Closes the Sheet (unless dismissible={false}); focus returns to the button that opened it. |
| Tab / Shift+Tab | Cycles inside the Sheet when it is modal; when it is not modal, focus can move to the page behind. |
| Opening a bottom sheet | Focus moves to the surface itself (role="dialog") so screen readers read SheetTitle + SheetDescription. |
| Handle (a button) | Enter / Space: next snap point (wraps around); ↑ / ↓: up / down one snap point. The label includes the current snap point, for example “Đổi chiều cao (45%)” (Change height). |
| Keyboard focus or a tap on an input at a low snap point | The sheet expands to its highest snap point so the focused element is not off screen. |
| Swipe to close | Always paired with a visible Close / Cancel button in the header or footer. |
Sheet is built on Radix Dialog: aria-labelledby points to SheetTitle, aria-describedby is only set when there is a SheetDescription, and the rest of the page is hidden from the accessibility tree when the sheet is modal. The handle is a button only when there are two or more snap points; with one it is decorative (aria-hidden).
Safe areas & the on-screen keyboard
The notch, the Dynamic Island and the home indicator cut into the visible area. A bottom sheet has to leave room for them.
- The footer (and the body when there is no footer) adds
env(safe-area-inset-bottom)to its bottom padding so buttons don't sit under the home indicator. - Maximum height =
min(highest snap point × 100dvh, 100dvh − env(safe-area-inset-top)), so the sheet never goes under the notch. - Use
dvhinstead ofvh: the real height changes as the address bar of a mobile browser shrinks and grows. env()is non-zero only when the page declaresviewport-fit=cover; addinteractive-widget=resizes-contentso the on-screen keyboard pushes the content up instead of covering the input.
<!-- index.html: enable env(safe-area-inset-*) and let the on-screen keyboard shrink the content -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>Reduced motion
When reduced motion is turned on in the OS, overlays appear and disappear instantly, but direct manipulation still follows the finger.
- Slide in / out, the overlay fade and the snap animation are all turned off (motion-reduce:animate-none, motion-reduce:transition-none).
- Dragging still moves the sheet with the finger. That is direct manipulation, not decorative animation.
- The rubber-band effect when dragging past the highest snap point only happens during the drag and stops on release.
- Carousel does not start autoplay when the user prefers reduced motion.
// CSS: Sheet already uses motion-reduce:animate-none / motion-reduce:transition-none.
// JS: behavior you write yourself reads the user's preference directly.
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
list.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });Do and don't
- Use one Sheet for both desktop and phone instead of two components switched by hand.
- Put the primary action in SheetFooter, within thumb reach and always visible.
- Always provide a visible close button; gestures are only shortcuts.
- Test on a real device with a collapsing address bar, the on-screen keyboard and reduced motion.
- A bottom sheet for destructive decisions. Use Dialog with modalType="alert".
- More than 3 snap points; users can't remember that many positions.
- Overlays on top of overlays (a Sheet in a Sheet, a Dialog over a Sheet), except for short confirmations.
- Required information in the lower part of a low snap point, where it is off screen.