DEV Community

Fabio Giolito
Fabio Giolito

Posted on

Create portal slots in Svelte using actions

Sometimes you want the content of a child component to live elsewhere in a parent. Django has template inheritance. Rails has content_for. Svelte has slots, but they send content from parent to child, not the other way around.

In this article we'll look into a solution for that.


The problem

Say you have a nice generic <Modal /> component that shows some content over the screen.

As you start creating multiple Modals across your app, you have to define them on the same component that triggers it to keep things readable, share state, have event listeners…

So this structure is not uncommon:

<!-- Home.svelte -->
<div class="content">
  <Main>...</Main>
  <Sidebar />
</div>
Enter fullscreen mode Exit fullscreen mode
<!-- Sidebar.svelte -->
<div class="sidebar">
  <Banner />
</div>
Enter fullscreen mode Exit fullscreen mode
<!-- Banner.svelte -->
<div class="banner">
  <button>View promotion</button>
</div>

<Modal>
   My custom promotion content in a modal
</Modal>
Enter fullscreen mode Exit fullscreen mode

And so far that's all fine. Your modal is rendered inside the .sidebar div, but you're using position: fixed to make it "break out" and appear over everything, so doesn't really matter where it is in the Dom, right?

Well... If your sidebar has overflow: hidden or any transform set, then your modal will be clipped inside it, and that's not what you want.

So it does matter where the modal is in the Dom. Your modal should be all the way up in Home.svelte so nothing "contains" it, but Svelte slots don't work that way, and there's no way for to send that modal up to .


The solution

We still want to define our modal inside and take advantage of all that gives us, but have it be rendered outside the box.

We're talking about manipulating the Dom, and that made me look into Svelte Actions. If you're not familiar, an action is just a function that gets a Dom node to play with.

// actions.js

// Portal action
export function portal(node, name) {
  // find an element with this ID somewhere in the document
  let slot = document.getElementById(name);
  // move this node to that element
  slot?.appendChild(node);

  return {
    destroy() {
      // remove the node when component is destroyed
      node.remove()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- Modal.svelte -->
<script>
  import { portal } from "./actions.js";
  export let isOpen = false;
</script>

{#if isOpen}

  <!-- Modal div with portal action -->
  <div use:portal={'modals'} class="modal">
    <slot></slot>
  </div>

{/if}
Enter fullscreen mode Exit fullscreen mode

Whenever isOpen is true, our modal div is created and the portal action is called. Then we find an element with id="modals" in our layout and move the modal div to that element.

So we just need to have a #modals div all the way up in our Dom, let's put it in Home.svelte

<!-- Home.svelte -->
<div class="content">
  <Main>...</Main>
  <Sidebar />
</div>

<!-- Portal slot for Modals -->
<div id="modals"></div>
Enter fullscreen mode Exit fullscreen mode

Does it work?

Yes! Surprisingly all Svelte features still work: Props, bindings, custom event listeners, lifetime cycle events, instance references…

We only moved the node, we didn't clone it or duplicate it, so Svelte's reference remains unchanged.

I admit this made me nervous at first, but I threw everything I could think of at it and it didn't break.

In any case let me know if I missed something and should not be doing this.


Multiple portals

Our portal action is generic, it takes a slot name as a parameter. This means we can use it with different slots in different parts of our app.

Here's a more extreme example:

<!-- Home.svelte -->
<Navbar id="navbar">
  <Logo />
  <Menu />
</Navbar>

<div class="container">
  <Main />
  <Sidebar>
    <div id="sidebar-logged-in-actions" />
    <RelatedContent />
  </Sidebar>
</div>

<LoggedInUser />
Enter fullscreen mode Exit fullscreen mode
<!-- LoggedInUser.svelte -->
<script>
  ...
</script>

{#if isLoggedIn}

  <!-- append this to navbar -->
  <div use:portal={'navbar'}>
    <!-- Todo: logged in user avatar with dropdown options -->
  </div>

  <!-- actions only available for logged in users -->
  <div use:portal={'sidebar-logged-in-actions'}>
    <FeedbackForm />
  </div>

{/if}
Enter fullscreen mode Exit fullscreen mode

Here's a Repl with some tests: https://svelte.dev/repl/2122ac70a8494ff4a6fca4ba61b512be?version=3.42.4


Update & Disclosure

After writing this article I found out that React has "Portals", which lead me to find the svelte-portal project that has pretty much the same solution but is already packaged and accounts for server-side rendering. Consider using it instead of hard-coding your own solution.

I renamed my action name from "Layout Slots" to "Portal" so people can find this article more easily and because it's a cooler name. Also updated article title to reflect the name change.

Top comments (0)