Portals
A portal renders a component’s output into a DOM node that lives outside the parent component’s tree. This is useful for modals, tooltips, dropdown menus, and any UI that needs to visually escape its parent’s layout.
How Portals Work in Whisq
Section titled “How Portals Work in Whisq”Since Whisq elements are real DOM nodes (not virtual), creating a portal is straightforward — mount your component into any DOM element:
import { signal, component, div, button, p, when, mount } from "@whisq/core";
const Modal = component((props: { onClose: () => void }) => div({ class: "modal-overlay", onclick: props.onClose }, div({ class: "modal-content", onclick: (e) => e.stopPropagation(), }, p("This is a modal!"), button({ onclick: props.onClose }, "Close"), ), ));
const App = component(() => { const showModal = signal(false); let cleanup: (() => void) | null = null;
const openModal = () => { showModal.value = true; // Mount modal into a separate DOM node const container = document.getElementById("modal-root")!; cleanup = mount( Modal({ onClose: closeModal }), container, ); };
const closeModal = () => { showModal.value = false; cleanup?.(); cleanup = null; };
return div( button({ onclick: openModal }, "Open Modal"), );});Your HTML needs a separate mount point:
<body> <div id="app"></div> <div id="modal-root"></div></body>A Reusable Portal Helper
Section titled “A Reusable Portal Helper”Wrap the pattern into a helper function:
import { mount, onCleanup } from "@whisq/core";
function portal(node: Node, target: string | HTMLElement) { const container = typeof target === "string" ? document.querySelector(target)! : target;
const dispose = mount(node, container); onCleanup(dispose); return node;}Use it inside components:
import { signal, component, div, button, p, when } from "@whisq/core";
const App = component(() => { const open = signal(false);
return div( button({ onclick: () => open.value = true }, "Show Tooltip"), when(() => open.value, () => portal( div({ class: "tooltip" }, p("I'm rendered in #tooltip-root")), "#tooltip-root", ) ), );});Use Cases
Section titled “Use Cases”Modals and dialogs — render above everything else, outside the app’s CSS stacking context:
portal( div({ class: "dialog-overlay" }, div({ class: "dialog" }, /* content */), ), "#modal-root",);Tooltips and popovers — position relative to a trigger but render in a top-level container to avoid overflow clipping:
portal( div({ class: "tooltip", style: () => `top: ${y.value}px; left: ${x.value}px`, }, "Tooltip text"), document.body,);Notification toasts — render into a fixed notification container:
portal( div({ class: "toast" }, "Saved successfully!"), "#toast-container",);Cleanup
Section titled “Cleanup”Always clean up portals when the parent unmounts. The portal() helper above uses onCleanup to handle this automatically. If you’re using mount() directly, store the dispose function and call it when done.
Next Steps
Section titled “Next Steps”- Components — Component lifecycle
- Lifecycle — onMount and onCleanup
- Error Boundaries — Handling errors in component trees