UIDex

Bottom Sheet

الورقة السفلية

BottomSheetDialog / .presentationDetents

also calledsheet، modal bottom sheet، half sheet، slide-up panel، bottom drawer، pull-up panel، بوتوم شيت، شيت، اللوحة السفلية، اللوح المنزلق من الأسفل، القائمة السفلية، الدرج السفلي

A bottom sheet is a surface attached to the bottom edge of the screen: it rises over the content, settles at one of a small set of heights, and follows a finger dragging it up and down. Two things define it together, the edge it belongs to, and a height the user owns rather than the designer alone. So it is not a modal dialog, which floats in the middle of the viewport, belongs to no edge, cannot be dragged, and is measured only by whether it blocks; not an action sheet, an iOS-born list of short commands with no detents and no free-form content; and not a drawer, which comes out of a side edge, travels along the inline axis, and therefore mirrors with interface direction. That last line is the sharpest one: a sheet moves on the block axis only, so its direction of travel never mirrors. The moment a form, a map, or a long list moves into an action sheet, the correct name becomes bottom sheet. A second split, no less important, lives inside the element itself: a modal sheet dims and freezes everything beneath it until it closes, while a persistent (non-modal) sheet stays on screen with no scrim and leaves the app below fully usable. That difference is one line of code: presentationBackgroundInteraction on iOS, BottomSheetScaffold instead of ModalBottomSheet on Android.

If you called it…

"the panel that slides up from the bottom""the half screen you drag up to make full""the little grey line you pull the sheet by""the menu that comes up from the bottom of the app""the popup stuck to the bottom of the screen"

Live specimen

Interact with the demo. Every part is real and numbered.

٩:٤١

المطاعم القريبة

١٢ مكاناً حولك

RTLاسحب المقبض. الحركة رأسية فلا تنعكس، لكن «إلغاء / تمّ» ينعكسان

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a mobile bottom sheet with two detents, medium and large. Make it modal by default with a scrim that swallows taps, using role="dialog" plus aria-modal="true" on the web, ModalBottomSheet on Android, or .sheet with .presentationDetents([.medium, .large]) on iOS, and let one switch turn it persistent (presentationBackgroundInteraction, or BottomSheetScaffold) without rewriting the content. Anchor it with inset-block-end: 0 and inset-inline: 0, and round only the top corners through border-start-start-radius and border-start-end-radius, never border-top-left-radius. Put a grabber at the top, centred with flex and justify-content: center, not with inset-inline-start: 50% and a translateX. Give it a 44pt touch target, and add a real close button beside it with an accessible name, because dragging is not available to everyone. Make the header a row with fixed DOM order (Cancel then Done) and justify-content: space-between, with no row-reverse. Scroll the content region only, with overscroll-behavior: contain, and hand the gesture off properly: the drag moves the sheet while scrollTop is 0, and once inner scrolling begins the sheet freezes until the list is back at its top. Size it in dvh rather than vh, add padding-block-end: env(safe-area-inset-bottom) to the footer with viewport-fit=cover, and lift the sheet above the keyboard via imePadding() or visualViewport instead of leaving it underneath. Use logical properties throughout: padding-inline, margin-inline, inset-inline, text-align: start. Write left or right in exactly one place, the side safe areas env(safe-area-inset-left) and env(safe-area-inset-right), which are physical by nature and have no logical form, so leave them alone and never flip them in RTL. Add no direction rule at all to the vertical motion. Dismiss on drag down, on the Android back button, on Escape on the web, and on a scrim tap; return focus to the trigger on close; and honour prefers-reduced-motion.

How it behaves right-to-left

This section is ours alone.

The grabber does not mirror

never mirrors

The grabber is horizontally centred and symmetric about its vertical axis, so it has nothing to mirror. The trap is in how you centre it, not in the shape: inset-inline-start: 50% with transform: translateX(-50%) looks correct because it works in LTR, but it breaks in RTL. The logical inset now measures from the right while translateX stays physical, so the pill lands a full width off centre. Centre it with a flex container and justify-content: center, or with margin-inline: auto, and drop absolute positioning entirely.

The header action row

mirrors

The Cancel/Done pair in the sheet header swaps ends: Cancel at the logical start, Done at the logical end, so Cancel sits on the right in Arabic and on the left in English. Keep DOM order fixed (Cancel first) and use justify-content: space-between so the row mirrors by itself with direction; never add flex-direction: row-reverse under [dir="rtl"], because the flip happens twice and visual order splits from keyboard and VoiceOver order. In SwiftUI the leading and trailing edges are logical and mirror on their own once the scene inherits layoutDirection = .rightToLeft. What does not mirror is .offset(x:), a physical shift. Replace it with padding or an alignment.

Safe-area insets are physical

never mirrors

env(safe-area-inset-left) and env(safe-area-inset-right) describe the device's own edges, the notch, the camera cutout, the curve of the glass , and CSS gives them no logical twin; there is no env(safe-area-inset-inline-start). Sweep the file replacing every left and right with a logical property and these two get flipped along with the rest, which pushes the sheet's content under the notch the moment the phone turns landscape. Leave them physical and fold your own spacing into the same declaration: padding-left: max(1rem, env(safe-area-inset-left)) and the same for padding-right, because a padding-inline written afterwards simply overwrites the safe value and puts the content back under the cutout. Android behaves the same way: WindowInsets.safeDrawing hands you left and right as the device's sides, not as start and end, so apply them as they come instead of mirroring them by hand.

The vertical drag is direction-neutral

never mirrors

Everything the sheet does happens on the block axis: the up and down drag, the detents, and the velocity threshold that decides a dismiss. None of that is touched by direction, so write no [dir="rtl"] rule for it and leave translateY alone. The mistake comes from treating a sheet like a drawer and adding a horizontal swipe to dismiss: that is an inline-axis gesture whose meaning inverts between languages, and on iOS it collides with the interactive back gesture, which sits on the leading edge. In an Arabic interface that is the right edge. Keep dismissal on three neutral paths: drag down, an explicit button, and a tap on the scrim.

What is inside the sheet

mirrors

Mirroring the sheet does not mirror what is inside it. On Android nothing flips at all unless android:supportsRtl="true" is in the manifest, and after that you still need paddingStart and layout_marginStart in place of their left-hand twins. Inside the rows, the leading icon and the chevron swap ends, and the chevron itself is directional: flip it with transform: scaleX(-1) under [dir="rtl"], with rtl:-scale-x-100 in Tailwind, or by taking the glyph from Icons.AutoMirrored in Compose. A star or a status icon never mirrors, and flipping one only makes it look broken. Set alignment with text-align: start rather than left, and wrap prices and phone numbers in <bdi> or unicode-bidi: isolate so the currency symbol does not slide to the wrong end of an Arabic line.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

SwiftUI.presentationDetents([.medium, .large])From iOS 16 this turns any .sheet into a multi-detent sheet; its partner .presentationDragIndicator(.visible) shows the grabber.
SwiftUI.presentationBackgroundInteraction(.enabled(upThrough: .medium))The switch that makes the sheet non-modal at the smaller detent: the app beneath stays tappable.
UIKitUISheetPresentationControllerThe layer underneath: detents, prefersGrabberVisible, and largestUndimmedDetentIdentifier to control when the dim appears.
Jetpack ComposeModalBottomSheet(sheetState = rememberModalBottomSheetState())Its states are SheetValue.PartiallyExpanded and Expanded; for the persistent variant use BottomSheetScaffold instead.
Material ComponentsBottomSheetBehavior.STATE_HALF_EXPANDEDThe Views implementation: halfExpandedRatio sets the middle detent, skipCollapsed and isDraggable tune the behaviour, and BottomSheetDialogFragment wraps it all.
HTML<dialog>.showModal() + ::backdropThe closest web approximation: top layer, scrim, and Escape for free. The detents and the drag are still yours to write.
CSSpadding-block-end: env(safe-area-inset-bottom)Keeps the footer button clear of the home indicator. It only works with viewport-fit=cover in the viewport tag.

See also

Updated · 2026-08-19