# Overlays
URL: /remote-components/docs/overlays

A frame can't draw outside its box. So a remote asks the host to draw menus and dialogs for it.

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

## Popover

The content is another remote component: a URL on the same origin, or an HTML string. Its props can hold functions. They call back into the remote that opened it.

A plain page calls `rc.overlay()` and gets a handle with `update(props)` and `close()`. Content from a string runs in an opaque frame, under the same network policy as the page that opened it. The pages here keep the overlay's markup in a `<template>` and pass it with their stylesheet.

\[interactive preview omitted]

widgets/menu-button.html

```html
<button id="sort" aria-haspopup="menu" aria-expanded="false">Sort: Newest</button>

<template id="menu">
  <div class="surface menu" role="menu" style="min-width: 160px"></div>
  <script>
    const list = document.querySelector(".menu");
    function render() {
      list.replaceChildren(
        ...(rc.props.items ?? []).map((item) => {
          const button = document.createElement("button");
          button.role = "menuitem";
          button.textContent = item;
          button.onclick = () => rc.props.onSelect(item);
          return button;
        }),
      );
    }
    render();
    rc.addEventListener("props-changed", render);
  </script>
</template>

<script>
  const sort = document.querySelector("#sort");
  let menu = null;

  const closeMenu = () => {
    menu?.close();
    menu = null;
    sort.ariaExpanded = "false";
  };

  sort.onclick = () => {
    if (menu) return closeMenu();
    sort.ariaExpanded = "true";
    menu = rc.overlay({
      kind: "popover",
      html: document.querySelector("#ui").outerHTML + document.querySelector("#menu").innerHTML,
      anchor: sort,
      placement: { side: "bottom", align: "start" },
      props: {
        items: ["Newest", "Oldest", "Most viewed"],
        onSelect: (item) => {
          sort.textContent = `Sort: ${item}`;
          closeMenu();
        },
      },
      onDismiss: () => {
        menu = null;
        sort.ariaExpanded = "false";
      },
    });
  };
</script>
```

```
// Nothing to set up. The overlay layer comes with <RemoteComponent>.
<RemoteComponent src="https://widgets.example/menu-button.html" />
```

In React, `useOverlay` is controlled, like a `<Popover open>`:

```
import { useRef, useState } from "react";
import { useOverlay } from "@assistant-ui/remote-components-react";

const SORTS = ["Newest", "Oldest", "Most viewed"];

export default function MenuButton() {
  const [open, setOpen] = useState(false);
  const [sort, setSort] = useState("Newest");
  const anchor = useRef<HTMLButtonElement>(null);
  useOverlay(open, setOpen, {
    kind: "popover",
    src: "../menu/",
    anchorRef: anchor,
    placement: { side: "bottom", align: "start" },
    props: {
      items: SORTS,
      onSelect: async (item: string) => {
        setSort(item);
        setOpen(false);
      },
    },
  });
  return (
    <button ref={anchor} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
      Sort: {sort}
    </button>
  );
}
```

The host places the popover with `position: fixed` next to the anchor's rect. It flips to the other side when there is more room there, shifts into the viewport and follows the anchor while you scroll. `matchAnchorWidth` makes it as wide as the anchor.

## Dialog

A dialog gets a backdrop, `aria-modal`, an inert page behind it and a focus trap. Initial focus goes to the content's first tabbable element.

\[interactive preview omitted]

widgets/dialog-button.html

```html
<div class="row">
  <button id="rename">Rename…</button>
  <span class="muted" id="result"></span>
</div>

<template id="confirm">
  <!-- Dialog content. The host draws the backdrop, traps focus and handles Escape. -->
  <form class="surface" style="padding: 16px; width: 320px; max-width: 100%; display: grid; gap: 12px">
    <div style="font-weight: 600">Rename project</div>
    <input aria-label="Project name" name="project" />
    <div class="row" style="justify-content: flex-end">
      <button type="button" id="cancel">Cancel</button>
      <button type="submit" class="primary">Save</button>
    </div>
  </form>
  <script>
    const form = document.querySelector("form");
    rc.addEventListener("props-changed", () => (form.project.value = rc.props.initial ?? ""), { once: true });
    form.onsubmit = (event) => {
      event.preventDefault();
      void rc.props.onSave(form.project.value);
    };
    document.querySelector("#cancel").onclick = () => void rc.props.onCancel();
  </script>
</template>

<script>
  const result = document.querySelector("#result");
  document.querySelector("#rename").onclick = () => {
    const dialog = rc.overlay({
      kind: "dialog",
      label: "Rename project",
      html: document.querySelector("#ui").outerHTML + document.querySelector("#confirm").innerHTML,
      props: {
        initial: "Atlas",
        onSave: (name) => {
          result.textContent = `Renamed to ${name}`;
          dialog.close();
        },
        onCancel: () => dialog.close(),
      },
      onDismiss: (reason) => (result.textContent = `Dismissed (${reason})`),
    });
  };
</script>
```

## Dismissal and focus

- Escape, in the content, the remote or your page, closes the topmost overlay. `onDismiss` gets `"escape"`, and `onOpenChange` gets `(false, "escape")`.
- A pointer-down outside a popover closes it with `"outside"`. Clicks on its own anchor don't, so the trigger can toggle it.
- When an overlay that had focus closes, focus goes back to the element that opened it. The ring shows after Escape and not after a click, as it would inline.
- Overlays nest. Overlay content can open its own.

## Who decides

The remote picks only the content's props. Sandbox, permissions, network and isolation come from your `<RemoteComponent>`. `overlayOrigins` on the provider says where content may come from: `"same-origin"` (default), `"any"`, a list, or `[]` to turn overlays off.

> [!warn]
>
> A remote can draw a convincing login form in a dialog. If you embed publishers you don't trust, label their overlays or turn them off.