Dialog
A modal box in the middle of the screen — a native dialog, in the top layer.
src/components/rahti_ui/dialog.rs
Basic
A trigger, a box, a header and a footer. The box is a `<dialog>` opened with `showModal()`, which is the whole reason this port is a fraction of what Radix's Dialog has to be: the top layer replaces the portal, `::backdrop` replaces the overlay, and the focus trap, the inert background and `Escape` are all the browser's.
shadcn's own demo. `DialogPortal` and `DialogOverlay` are not in it, because both dissolved: a modal `<dialog>` is already in the top layer and cannot be covered, and `::backdrop` is the overlay. A page selecting on `[data-slot=dialog-overlay]` will find nothing.
The trigger is a bare `<button>` wearing `button_variants(…)`. shadcn's has no class list of its own either — its `asChild` was always meant to hand the look to a Button, and a `<button>` inside a `<button>` is not markup. The same is true of `DialogClose`, which is why "Cancel" above is given the outline classes explicitly.
Open it and press Tab. Focus is trapped inside the box and comes back to the trigger on close, the page behind is inert, and none of that is this component's doing — `showModal()` is where it comes from.
The close button
There are two of them and they are not the same thing. The corner X belongs to the content and is drawn inside it, which is why the component writes it rather than the call site. A `DialogClose` is a button a page writes wherever it likes — both are `data-slot="dialog-close"`, and the script listens to all of them.
`hide_close` is spelled the other way round from shadcn's `showCloseButton={true}` because an unwritten Rahti `bool` prop is `false` — a positive spelling would have taken the X off every dialog written. `Separator`'s `announced` and `Accordion`'s `instant` are the same trick.
This is the one place the port differs from `Sheet` beyond the geometry. shadcn's `DialogClose` is the bare primitive with no class list; the corner X is a *second* close button that happens to share the `data-slot`. Keeping those apart is what stops a footer button from inheriting `absolute top-4 right-4` and landing in the corner.
The footer's own Close
shadcn's footer takes a `showCloseButton` of its own, which appends an outline "Close" after whatever the footer was given. It is a real `DialogClose`, so it takes the animated path like every other one.
Two props, two spellings, and both match shadcn. `showCloseButton` is `true` on the content and `false` on the footer, so the content's became `hide_close` and the footer's stayed `close_button` — an unwritten Rahti `bool` is `false`, which is already the right default for one of them and the wrong one for the other.
Narrow the window. The footer is `flex-col-reverse` under `sm` and `flex-row` above it, so the child written *last* ends up on top when the buttons stack and on the right when they sit in a row. Both are the conventional place for a primary action, and it is shadcn's own asymmetry rather than an accident.
Which is the catch in the second example, and it is shadcn's too: `close_button` appends *after* the children, so on a phone the Close is the one on top and Publish sits under it. When a dialog has a real primary action, write both buttons yourself — a `DialogClose` first, the action last — and leave `close_button` for the dialogs whose only button is Close.
Width
The box is `w-full max-w-[calc(100%-2rem)] sm:max-w-lg`, and a wider one is a `class` override rather than a variant — shadcn has no `size` prop here.
The merge being literal text until PulsePoint mounts costs nothing here, and that is not luck. A closed `<dialog>` is `display: none` by UA rule, so nobody can see the box before the runtime has resolved its width — the one component in this library where an unresolved class list is invisible by construction.
Scrolling
Two ways, and the second one is free. shadcn's pattern bounds the box and hands the middle row the leftover height; the browser's own is to cap a modal at the viewport and let it scroll whole.
The first one is three utilities: `max-h-[80vh]` to bound the box, `grid-rows-[auto_1fr_auto]` to give the middle the leftover height, and `min-h-0` on that middle region so it is allowed to be shorter than its content. Without the last one a grid item refuses to shrink and the footer is pushed off the bottom.
`grid` rather than a flex column is shadcn's own choice for the box, and this is what it buys — the row template is already there to be overridden.
Nested
A confirmation inside a dialog. The inner root is written inside the outer box's footer and is an ordinary `<Dialog>` — there is nothing special to say, which is the point of the filter that makes it work.
Two roots, two scripts, and neither touches the other's parts. The script only claims elements whose nearest `[data-slot=dialog]` is its own root — without that filter the outer dialog's `querySelectorAll` would find the inner one's trigger and close buttons and wire them to the wrong box.
The stacking is the browser's too. Two modal dialogs are two entries in the top layer, and the later one is above — no `z-index` anywhere, and the second `::backdrop` darkens the first.
What the browser does
Most of this component, and the reason the file is short.
`showModal()` puts the box in the **top layer**, which is above every stacking context on the page by definition. That is Radix's Portal, gone — there is no `transform` or `overflow: hidden` ancestor that can clip a dialog out of view, because it is not painted in the page's tree at all.
It paints **`::backdrop`**, which is Radix's Overlay, gone. The component keeps only the colour and the fade — `backdrop:bg-black/50` and a transition on opacity. A transition rather than shadcn's keyframes, because those set custom properties on the animating element and `::backdrop` inherits them unevenly across engines.
It **traps focus** and restores it to whatever opened the dialog, makes the rest of the page **inert**, sets `role="dialog"` and `aria-modal`, and closes on **Escape**. Six things, none of them written here.
What is left for the script: calling `showModal()`, closing on a backdrop click, locking the body's scroll — the browser makes the page inert but does not stop it scrolling — pointing `aria-labelledby` and `aria-describedby` at the title and description, and holding the box on screen for the length of its exit animation.
That last one is the only genuinely fiddly part. `dialog.close()` hides the element the instant it is called, so an exit animation would never be seen: the script writes `data-state="closed"`, lets `zoom-out-95` play, and closes on `animationend` — with a 250ms timer as the net for a browser that never fires one, or a reader with `prefers-reduced-motion` and no animation at all.
The port
What came from where, what dissolved, and the one string this file adds.
The class strings are the published registry's — `new-york-v4` — as every component in this library is. The Base UI style writes the same design into marker classes that a separate stylesheet defines, and there is no such stylesheet here.
Two of shadcn's ten exports are gone rather than ported. `DialogPortal` is nothing, and `DialogOverlay` is `::backdrop`. A page that selects on `[data-slot=dialog-portal]` or `[data-slot=dialog-overlay]` finds nothing, which is worth knowing before reaching for one.
One string was added: `not-open:hidden right-auto bottom-auto`. The first is load-bearing — the UA hides a closed dialog with `display: none`, the box's `grid` is an author rule and beats it, and without this a closed dialog would paint in the middle of every page it is on. The other two clear insets the UA sets on all four sides where shadcn writes only two; in LTR the browser drops the right ones on its own, and in RTL it drops `left-[50%]` instead.
One string was deliberately *not* added. `Sheet` clears the UA's `max-height` on a modal because an `h-full` drawer needs the viewport edges; a Dialog wants that cap, because it is what makes a tall box scroll rather than run off the screen.
And one divergence from `Sheet`: `DialogClose` carries no class list of its own, matching the registry. The corner X is a separate internal button that owns `absolute top-4 right-4`, so a close button in a footer stays in the footer.
The boundary
Where the state lives, and why it is not PulsePoint's.
The root is `display: contents`, so it is not in the layout — the trigger inside it sits exactly where it was written, as an item of whatever row the `<Dialog>` was put in. Radix's Root renders no element at all; this one has to, because the script needs somewhere to live and something to search from.
None of the script's state is `pp.state`. A state change re-renders the block and hands the attribute reconciler a chance to put the server's `data-state="closed"` back over the one the script just wrote — so the open and closed states live in the DOM and in the `<dialog>`'s own `open` property, which is where the browser keeps them anyway.
Which is also why binding `data-state` or `open` from a call site is a fight the script wins. To drive a dialog from a page's own state, click its trigger or its close button from an effect; both are ordinary buttons with a stable `data-slot`.
A `.class(…)` on a part becomes `{twMerge(…)}` and is literal text until PulsePoint mounts. On this component that is invisible: everything overridable is inside a `<dialog>` that has no `open` attribute, and a closed dialog is `display: none` by UA rule. Nobody sees the unresolved state.