Portals

In front-end development, sometimes we need to display a component (like a modal or tooltip) in a different place from where it was triggered. The issue is that the UI element needs to be rendered in a different part of the DOM tree, and the component that triggers the element needs to have a way to affect the location of rendering.

In other frameworks, this is often solved by a dedicated API such as createPortal(). However such APIs don't work well with server-side rendering and so an alternative approach is needed.

Qwik UI

Luckily, there is native behavior that handles this for us, called the top layer. The Qwik UI team has done an awesome job of filling in the gaps, and allowing us to use this behavior in production.

Modals

We use modals when we do not want the user to interact with the rest of the page. The rest of the content is inert, or unable to be interacted with.

Qwik UI's modal component uses the dialog element's showModal method, which is well-supported in browsers and automatically handles placing UI outside of the HTML Document.

It also includes behavior such as focus and scroll locking, alert dialogs, automatic entry and exit animation support, and backdrop animations. At the time of writing, support for the dialog element is currently at 96%.

Non-modal UI

If the UI element can interact with the rest of the page, then it is not modal.

Some examples of non-modal components are:

  • Popovers
  • Overlays
  • Toasts
  • Tooltips
  • Dropdown menus
  • Selects
  • Comboboxes

MDN's popover API replaces the need for portals in non-modal components. Support is also in every major browser. At the time of writing, it's at ~73%.

Qwik UI has taken it upon themselves by providing a polyfill with feature parity to the native spec. You can use the Popover API's behavior in production today with Qwik UI's Popover component.

In the case of components like a select or combobox, Qwik UI also provides the ability to opt-in to "floating" behavior. For example, when a listbox anchors to an input element. You can do so by adding floating={true} to your Popover component. This will execute a bit of extra javascript needed for floating behavior.

It is intentionally opt-in, at some point the CSS Anchor API will provide a native solution, and so there should be an easy migration path when that receives more general support.

Because these solutions are built on top of the native specs, that also means there's less javascript we need to prefetch, and therefore less work that needs to be done!

Both Qwik UI's popover and modal components can be used regardless of meta-framework or microfrontend, as long as there is support for Qwik.

Custom Portals

If the behavior of the above components do not fit the use case, there is also the ability to create a custom portal component in Qwik. We'll create a modal component from scratch.

The following is an SSR portal implementation using Qwik City. If you are using multiple frontend frameworks alongside Qwik, you may prefer a React-like portal implementation.

The fundamental problems to solve are:

  1. decide where the popup should be rendered in the application. (Let's call this <Portal>)
  2. have a way to communicate with the <Portal> to let it know when and what component should be rendered (from the component that triggers the popup.) This is achieved through <PortalProvider/> component.

Solution

Let's break down the solution into steps:

  1. Create a PortalProvider component that is responsible for managing the portals.
  2. Place the <PortalProvider> into the top-level layout.tsx component.
  3. Create a PortalAPI that can be used to communicate with the PortalProvider.
  4. Create <Portal> component which renders the portal content.

Using the PortalProvider

Let's assume that we already have a PortalProvider and let's focus on how it is used first.

  1. We get a hold of the PortalProvider API through:
    const portal = useContext(PortalAPI);
  2. We then use the PortalProvider API to show a popup:
    portal('modal', <PopupExample name="World" />)
  3. Finally we can use PortalCloseAPI API to hide the popup:
    const portalClose = useContext(PortalCloseAPI);
    portalClose();

The full source code can be found here:

import {
  $,
  component$,
  useContext,
  useStylesScoped$,
  useTask$,
} from '@builder.io/qwik';
import { PortalCloseAPIContextId, PortalAPI } from './portal-provider';
import PopupExampleCSS from './popup-example.css?inline';
import { useLocation } from '@builder.io/qwik-city';
 
export default component$(() => {
  // Retrieve the portal API
  const portal = useContext(PortalAPI);
  // This function is used to open the modal.
  // Portals can be named and each portal can have multiple items rendered into it.
  const openModal = $(() => portal('modal', <PopupExample name="World" />));
 
  // Conditionally open the <Portal/> on the server to demonstrate SSR of portals.
  const location = useLocation();
  useTask$(() => {
    location.url.searchParams.get('modal') && openModal();
  });
  return (
    <>
      <div>
        [ <a href="?modal=true">render portal as part of SSR</a> |{' '}
        <a href="?">render portal as part of client interaction</a> ]
      </div>
      <button onClick$={openModal}>Show Modal</button>
    </>
  );
});
 
// This component is shown as a modal.
export const PopupExample = component$<{ name: string }>(({ name }) => {
  useStylesScoped$(PopupExampleCSS);
  // To close a portal retrieve the close API.
  const portalClose = useContext(PortalCloseAPIContextId);
  return (
    <div class="popup-example">
      <h1>MODAL</h1>
      <p>Hello {name}!</p>
      <button onClick$={() => portalClose()}>X</button>
    </div>
  );
});

The styling for the PopupExample component is (portal-provider.css):

.modal {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  outline: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
  -webkit-tap-highlight-color: transparent;
}

Implementing the PortalProvider

A PortalProvider is a component that is responsible for rendering the popup. It also provides a context API that can be used to show/hide the popup.

  1. Create the PortalProviderContext that will be used to communicate with the PortalProvider.
    useContextProvider(PortalProviderContext, {
     show: $(<T extends {}>(component: Component<T>, props: T) => {...}),
     hide: $(() => {...})
    });
  2. Conditionally render the component:
    {
      // Conditionally render the modal
      modal.value && <div class="modal">
        <modal.value.Component {...modal.value.props} />
      </div>
    }

Full implementation can be found here:

import {
  $,
  Slot,
  component$,
  createContextId,
  useContext,
  useContextProvider,
  useSignal,
  useStylesScoped$,
  type ContextId,
  type QRL,
  type Signal,
  type JSXOutput,
} from '@builder.io/qwik';
import CSS from './portal-provider.css?inline';
 
// Define public API for opening up Portals
export const PortalAPI = createContextId<
  /**
   * Add JSX to a portal.
   * @param name portal name.
   * @param jsx to add.
   * @param contexts to add to the portal.
   * @returns A function used for closing the portal.
   */
  QRL<
    (name: string, jsx: JSXOutput, contexts?: ContextPair<any>[]) => () => void
  >
>('PortalProviderAPI');
 
export type ContextPair<T> = { id: ContextId<T>; value: T };
 
// Define public API for closing Portals
export const PortalCloseAPIContextId =
  createContextId<QRL<() => void>>('PortalCloseAPI');
 
// internal context for managing portals
const PortalsContextId = createContextId<Signal<Portal[]>>('Portals');
 
interface Portal {
  name: string;
  jsx: JSXOutput;
  close: QRL<() => void>;
  contexts: Array<ContextPair<any>>;
}
 
export const PortalProvider = component$(() => {
  const portals = useSignal<Portal[]>([]);
  useContextProvider(PortalsContextId, portals);
 
  // Provide the public API for the PopupManager for other components.
  useContextProvider(
    PortalAPI,
    $((name: string, jsx: JSXOutput, contexts?: ContextPair<any>[]) => {
      const portal: Portal = {
        name,
        jsx,
        close: null!,
        contexts: [...(contexts || [])],
      };
      portal.close = $(() => {
        portals.value = portals.value.filter((p) => p !== portal);
      });
      portal.contexts.push({
        id: PortalCloseAPIContextId,
        value: portal.close,
      });
      portals.value = [...portals.value, portal];
      return portal.close;
    })
  );
  return <Slot />;
});
 
/**
 * IMPORTANT: In order for the <Portal> to correctly render in SSR, it needs
 * to be rendered AFTER the call to open portal. (Setting content to portal
 * AFTER the portal is rendered can't be done in SSR, because it is not possible
 * to return back to the <Portal/> after it has been streamed to the client.)
 */
export const Portal = component$<{ name: string }>(({ name }) => {
  const portals = useContext(PortalsContextId);
  useStylesScoped$(CSS);
  const myPortals = portals.value.filter((portal) => portal.name === name);
  return (
    <>
      {myPortals.map((portal, key) => (
        <div key={key} data-portal={name}>
          <WrapJsxInContext jsx={portal.jsx} contexts={portal.contexts} />
        </div>
      ))}
    </>
  );
});
 
export const WrapJsxInContext = component$<{
  jsx: JSXOutput;
  contexts: Array<ContextPair<any>>;
}>(({ jsx, contexts }) => {
  contexts.forEach(({ id, value }) => {
    // eslint-disable-next-line
    useContextProvider(id, value);
  });
  return (
    <>
      {/* Workaround: https://github.com/QwikDev/qwik/issues/4966 */}
      {/* {jsx} */}
      {[jsx].map((jsx) => jsx)}
    </>
  );
});

The styling for the PortalProvider component is (portal-provider.css):

.modal {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  outline: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
  -webkit-tap-highlight-color: transparent;
}

Adding PortalProvider to root layout.tsx

import { Slot, component$ } from '@builder.io/qwik';
import { Portal, PortalProvider } from './portal-provider';
 
export default component$(() => {
  // 1. Wrap a root component with a <PortalProvider> to enable portal API.
  //    The <PortalProvider> component will provide a context API to
  //    allow other components to create portals.
  // 2. Add <Portal/> to where you want the portals to be rendered.
  //    (<Portal/>s have names and so you can have multiple <Portal/> locations.)
  return (
    <PortalProvider>
      <Slot />
      <Portal name="modal" />
    </PortalProvider>
  );
});

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • mhevery
  • thejackshelton
  • fabian-hiller
  • igorbabko
  • aendel