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…
Live specimen
Interact with the demo. Every part is real and numbered.
المطاعم القريبة
١٢ مكاناً حولك
مطعم البرج
Anatomy: every part, named
Hover a row to locate it in the demo above.
Build prompt
How it behaves right-to-left
This section is ours alone.
The grabber does not mirror
never mirrorsThe 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
mirrorsThe 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 mirrorsenv(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 mirrorsEverything 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
mirrorsMirroring 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.
See also
Updated · 2026-08-19