UIDex

Drawer / Side Panel

الدرج الجانبي

role="dialog" / inset-inline-start

also calledside panel، side sheet، navigation drawer، slide-out panel، off-canvas menu، flyout panel، درور، سايد بانل، اللوحة الجانبية المنزلقة، قائمة جانبية منزلقة، درج التنقّل، ورقة جانبية

A drawer is a temporary surface that slides in from one edge of the viewport, stays welded to that edge, and runs along its full length until it is dismissed. What defines it is an anchor edge paired with a context that stays visible: the user can still see the page they came from, which is why drawers are the right home for navigation, filters, and detail panels. It is not a modal dialog, because a dialog floats in the middle of the viewport, belongs to no edge, and exists to extract a decision before you continue. It is not a sidebar either: a sidebar is permanent page furniture that takes part in the layout and pushes content aside rather than covering it, and it closes with neither Escape nor an outside click, because it does not close at all. Nor is it a bottom sheet, which is the mobile counterpart, anchored to the bottom edge and travelling along the block axis instead of the inline one. Modality is not part of the definition: a drawer may be modal, with a scrim, a focus trap, and a scroll lock, or non-modal, leaving everything behind it clickable and Tab-reachable, so blocking is an axis entirely independent of anchoring. The practical test: if the user cannot dismiss it and carry on with what they were doing, what you built is a dialog in the shape of a drawer.

If you called it…

"the menu that slides out from the side when you tap the hamburger""the panel that comes in from the edge and dims the page a bit""the filters that open on the side without leaving the page""the side thing you swipe away to close""the off-canvas nav on mobile"

Live specimen

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

المشاريع
inert
RTLالحافّة المرتكِزة نفسها تنقلب، والسحب معها

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a navigation drawer that opens from the logical start edge. Make it an element with role="dialog" and an aria-labelledby pointing at its visible heading, and add aria-modal="true" only in the modal variant. Anchor it with logical properties exclusively: inset-inline-start: 0, inset-block: 0, inline-size: min(320px, 88vw), and border-inline-end for the divider. No rule may contain left, right, width, or border-right. Define --dir: 1 on the root and --dir: -1 under [dir="rtl"], and drive enter and exit with transform: translateX(calc(-100% * var(--dir))) rather than by animating inset. The trigger carries aria-haspopup="dialog" and an aria-expanded that tracks state moment to moment, and focus returns to that trigger on close. In the modal variant: a scrim at inset: 0 that closes on click, a real focus trap via inert on the siblings of the drawer rather than aria-modal alone, and a page scroll lock with scrollbar-gutter: stable on the root plus overscroll-behavior: contain on the panel. In the non-modal variant: no scrim, no trap, no scroll lock, and everything behind stays clickable and Tab-reachable. Offer three ways out: an × at inset-inline-end, the Escape key, and a drag of the panel back toward its edge. Derive the drag sign at event time from getComputedStyle(el).direction, because clientX is physical and is never flipped under RTL. Honour prefers-reduced-motion by dropping the slide down to a fade.

How it behaves right-to-left

This section is ours alone.

The anchor edge itself flips

mirrors

This is the flip everyone forgets: what mirrors is not the contents of the drawer but the edge it hangs from. A navigation drawer that comes out of the left of the screen in English has to come out of the right in Arabic, because it is bound to where reading starts, not to physical left. Write it as inset-inline-start: 0 with inset-block: 0 and an explicit inline-size, delete left and width entirely, and turn the divider between panel and content into border-inline-end instead of border-right. The one legitimate exception is a drawer tied to a fixed meaning rather than to reading, such as a detail panel that always opens at the end of a table, and even that is written inset-inline-end, never right.

The slide needs a direction variable

mirrors

translateX has no logical counterpart: the translate function works on the physical X axis whatever the page direction, and there is no translate-inline in the spec. So a hard-coded translateX(-100%) makes the Arabic drawer start off-screen on the side opposite its own edge and fly across the whole viewport before it lands. Define --dir: 1 on the root and --dir: -1 under [dir="rtl"], then write transform: translateX(calc(-100% * var(--dir))). The alternative is animating inset-inline-start from -320px to 0, which mirrors for free but forces layout every frame, whereas transform runs on the compositor.

The swipe-to-close direction reverses

mirrors

Pointer and touch coordinates are always physical: clientX grows toward the right of the screen in Arabic exactly as in English, and the browser never flips it for you under RTL. So a condition like if (deltaX < -60) close() is an outright bug, not a matter of taste: in Arabic a start-anchored drawer sits on the right, so the swipe that puts it away travels right and yields a positive deltaX that never satisfies the test and the panel just snaps back, while the one gesture that does satisfy it, a drag leftward, away from the anchor edge and into the page , closes the drawer by hauling it across the content, the exact opposite of what the hand is doing. Read the direction at event time with const sign = getComputedStyle(panel).direction === "rtl" ? -1 : 1, and multiply the delta by it before any comparison. The same fix is owed to a velocityX threshold and to ArrowLeft / ArrowRight if you bind them.

The close button and the shadow

mirrors

The two move together to the opposite side. The × sits at the logical end of the panel header: make it the last child of a flex row with justify-content: space-between and it settles by itself, or pin it with inset-inline-end instead of right. The shadow is the quieter trap: box-shadow has no logical form in CSS at all, and a value like 8px 0 24px throws the shadow off the outer edge of the viewport after the flip, where the browser clips it, leaving the panel with no lift over the content it is meant to sit above. Write the horizontal offset as calc(8px * var(--dir)), or zero it and let the blur carry it alone, since blur is symmetric by nature. The × glyph is symmetric about its vertical axis so it is never flipped, while a collapse chevron is a directional icon and does mirror with it.

What does not mirror: block axis and blocking

never mirrors

Page direction governs the inline axis only. A drawer anchored to a block edge, the bottom for instance, never moves between the two languages: inset-block-end: 0 is the same bottom in both, and its deltaY drag axis keeps the same sign, so it needs no direction variable at all. The scrim is neutral too: inset: 0 covers everything and contains nothing that could flip. Scroll lock behind the drawer is neutral as well, since overflow: hidden and overscroll-behavior: contain have nothing to do with direction, and exactly one thing inside it does flip: the vanished scrollbar sits at the logical inline-end edge, which is the left of a dir="rtl" page, so compensate with scrollbar-gutter: stable rather than a hard-coded padding-right.

In code

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

ARIArole="dialog" + aria-modal="true"Modality is a decision separate from the anchor; a non-modal drawer drops aria-modal entirely.
HTML<dialog>.showModal()Hands you ::backdrop, Escape, and the top layer for free, but its default position is centred, so the anchoring is still your job.
shadcn/ui<SheetContent side="left">Built on Radix Dialog; side lives on SheetContent, not on Sheet, and its values are purely physical, so swapping them under RTL is on you.
Material UI<Drawer anchor="left" variant="temporary">variant="permanent" turns the same component into a sidebar: this is the prop that decides which element you actually built.
Ant Design<Drawer placement="left" mask={false} />mask={false} makes it non-modal: one prop decides whether the page behind stays usable.
Jetpack ComposeModalNavigationDrawer / DismissibleNavigationDrawer / PermanentNavigationDrawerThree names for one shape: modal, dismissible, and permanent. The last is a sidebar carrying the drawer name.
CSSinset-inline-startThe property that lets the anchor edge follow the reading direction on its own.

See also

Updated · 2026-08-19