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.
  • inert and aria-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.