Modals and portals
Anchoring inside dialogs.
Why modals are hard
A step can point at a control inside a dialog, a popover, or a command palette. All of them render in their own portal, and each brings a problem:
- Stacking. A dialog at a high z-index paints over a scrim beneath it.
inertandaria-hidden. Dialog libraries mark everything outside the dialog as inert, which would make the tour’s own buttons unclickable.- Focus traps. A trap inside the dialog cannot reach a sibling portal, so Tab never lands on Next.
This is where most tour libraries break, and it is not a small edge case.
What cairnkit does
When the target lives inside a dialog, the overlay portals into that dialog rather than onto document.body. It then inherits the dialog’s stacking context, its interactivity, and its focus scope — all three problems solved by placement.
Nothing to configure
Point a step at an anchor inside your dialog. Detection is automatic — it looks for [role="dialog"], [role="alertdialog"] and dialog[open], which covers Radix, Headless UI, MUI and native dialogs.
{ anchor: anchors.settings.difficulty, title: "Inside a modal" }Opening the modal mid-tour
Use a click step on the button that opens it. The user opens the dialog themselves, and the next step’s anchor is waited for until it mounts.
steps: [
{
anchor: anchors.page.settingsButton,
title: "Open settings",
body: "The next step is inside the dialog.",
advanceOn: { type: "click" },
},
{ anchor: anchors.settings.difficulty, title: "Inside a modal" },
]Closing it again
The step after a modal step is usually behind the modal. Press Next and the spotlight lands on something the user cannot reach — the tour looks broken while being technically correct.
Steps are data in a flow file, so onExit cannot close over the state that opens the dialog — that state lives several components away. The component that owns it publishes a named action instead:
import { useTourAction } from "@cairnkit/react";
function InviteSettings() {
const [open, setOpen] = useState(false);
// Published while this component is mounted, withdrawn when it unmounts.
useTourAction("settings:close", () => setOpen(false));
return <Dialog open={open} onOpenChange={setOpen}>…</Dialog>;
}and the step calls it by name:
{
anchor: anchors.settings.difficulty,
title: "Inside a modal",
body: "Set the difficulty, then continue.",
onExit: (direction, ctx) => ctx.run("settings:close"),
}onExit is awaited, so if your close is animated, return a promise that settles when it finishes. The next step measures its target the moment this resolves, and a rect read mid-transition is the wrong rect.
useTourAction("settings:close", async () => {
setOpen(false);
await new Promise((r) => setTimeout(r, 300)); // match your close duration
});The direction argument is "forward" or "back". Usually you want to close either way — the control that opened the dialog is behind it too — but it is there when the two differ. onEnter(ctx) is the mirror image, for putting the app into the state a step describes.