A frame can't draw outside its box. So a remote asks the host to draw menus and dialogs for it.
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.
<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.
<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.
onDismissgets"escape", andonOpenChangegets(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.
Warning
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.