Drawer
A panel that comes in from an edge — and can be thrown back out with a drag.
src/components/rahti_ui/drawer.rs
Basic
shadcn's own demo. The panel is a `<dialog>` opened with `showModal()`, so the top layer, the focus trap, the inert background and `Escape` are all the browser's — and the motion on top of that is an inline transform, because a drag has to be able to interrupt it.
Drag it down. Past a quarter of its own height it goes, and under that it springs back — unless the gesture was fast, in which case a short flick throws it anyway. That second test is what stops a quick swipe from feeling stuck.
shadcn's `DrawerPortal` and `DrawerOverlay` are not in this markup, because both dissolved: a modal `<dialog>` is already in the top layer and `::backdrop` is the overlay. A page selecting on `[data-slot=drawer-overlay]` will find nothing.
Or press the dimmed area beside it, which closes it too — Vaul's `dismissible` default. That is the opposite of an Alert Dialog, where the same press deliberately does nothing, and the difference is the point of those two components. A drag that ends out over the backdrop is still a drag, though: the dismissal needs both ends of the gesture out there.
The grab bar at the top is shadcn's, and it is drawn only for a bottom panel — a thumb reaching up the screen is the gesture it hints at. It is a hint and not a control: the drag works anywhere on the panel, and in all four directions.
Directions
Four edges. The drag follows: each panel is dragged toward the edge it came from, and the script works the axis and the sign out from the attribute.
One attribute, not one class layer. shadcn's class list carries all four directions at once — twenty utilities, each behind a `data-[vaul-drawer-direction=…]:` variant — and the attribute on the panel picks which apply. Changing direction changes the attribute and nothing else.
That attribute keeps Vaul's name even though Vaul is not here. Renaming it would mean rewriting all twenty utilities and quietly breaking any shadcn snippet pasted into this project — it is a string, and this is the string shadcn's design is written against.
Notice the header: centred for top and bottom, left-aligned from `md` up. It reads the same attribute through `group/drawer-content`, so no direction is passed down to it either. The handle does the same, which is why only the bottom panel has one.
The drag
The part the platform has nothing for, and the reason this component exists apart from Sheet.
**A press starts a drag** unless it landed on something interactive — a button, a link, an input, a label, anything `contenteditable` — or inside a scrollable region that is not yet at the edge the drag would reveal. Without those two tests a drawer cannot hold a list or a form, because every attempt to scroll or type would throw the panel instead.
**Moving drags the panel along its own axis.** Toward the edge it follows the pointer exactly; the other way it is damped to a quarter, so the panel feels attached rather than free. Nothing moves at all until the pointer has travelled a few pixels, so a press that was meant as a click stays one.
**Releasing either dismisses or snaps back.** Past a quarter of the panel's own size it goes; under that it returns — unless it was moving faster than 0.4px/ms, which throws it regardless of distance.
While a drag is running the panel carries `data-dragging`, which is there for a page's own selectors — a shadow that deepens, a cursor that changes.
`[data-drawer-no-drag]` on anything inside the panel takes it out of the drag, for the cases this cannot guess: a canvas, a map, a custom slider.
With a scrolling list
A drawer that holds content taller than itself. `max-h-[80vh]` is shadcn's cap for a top or bottom panel, and the region inside it scrolls.
This is the test the drag has to pass to be usable. Scroll down inside the list and drag: nothing happens to the panel, because the scroller owns the gesture. Scroll back to the top and drag again: now the panel moves.
The check walks up from whatever was pressed to the panel, looking for a scrollable ancestor and asking whether it is already at the edge the drag would reveal. A bottom panel needs `scrollTop === 0` before it will follow a downward drag.
With a form
The other half of the same problem. A drawer with inputs in it has to let those inputs be used.
Press and drag from the middle of a text field: the selection works and the panel does not move. Press the padding beside it and drag: the panel follows. That is `DRAG_IGNORE` — a drag that begins on a control steals the press from it, so it never begins there.
Without the handle
For a panel that is dismissed by its own buttons, or one whose content already reads as grabbable.
`hide_handle` is spelled the other way round from a positive prop because an unwritten Rahti `bool` is `false` — a `handle` prop would have taken the bar off every drawer written. `Sheet`'s `hide_close` and `Separator`'s `announced` are the same trick.
Drawer, or Sheet
Two components that look the same in a screenshot and are not interchangeable.
They look alike and they are not the same component. A Sheet is a Radix Dialog docked to an edge: it opens, it closes, and the pointer never moves it. A Drawer is a physical object — you can push it, it follows your finger, it snaps back if you let go too early, and it flies away if you throw it.
Reach for a Sheet on a desktop-first surface and a Drawer where a thumb is doing the work. shadcn's own documentation pairs a Drawer with a Dialog behind a media query for exactly that reason: the dialog on a desktop, the drawer on a phone.
They also animate differently, and that difference is forced. A Sheet slides with `data-[state=closed]:slide-out-to-right` and a script that waits for `animationend`; a Drawer cannot, because a keyframe animation cannot be interrupted half way and a drag has to interrupt it. So the Drawer's motion is an inline transform — which is also why shadcn's drawer class list has no animation utilities in it at all.
The port
What came from where, what dissolved, and what is deliberately missing.
The class strings are the published registry's — `new-york-v4`, the Vaul-based one — verbatim, including the attribute name.
Two of the ten exports are gone rather than ported: `DrawerPortal` is nothing, and `DrawerOverlay` is `::backdrop`. One was added, `DrawerHandle`, because shadcn's grab bar is an unnamed `<div>` inside the content and a page that wants to move it needs a name for it.
Two strings were added, both about the UA stylesheet a `<div>` never had to answer. `not-open:hidden`, because the panel's `flex` is an author rule that beats the UA's `display: none` and a closed drawer would otherwise paint in the corner of every page. And a per-direction reset — a bottom panel clears `top-auto w-auto max-w-none`, a left one clears `right-auto max-h-none` — because the UA pins a dialog on all four insets, sizes it `fit-content`, and caps a modal at roughly the viewport.
Three things Vaul does are not carried. **Snap points** are a second interaction layered on the drag, and shadcn's class strings say nothing about them. **The scaled background** needs a `[data-vaul-drawer-wrapper]` around the whole document, which is a requirement on every page rather than on this component. And **non-modal** would give back the top layer, the focus trap and the inert background, which is most of what `showModal()` was for.