Build Simple Reusable Widgets Using Slots

A new feature in LiveView called "slots" can help make your components more composable and reusable. This post is about getting started with slots to build a simple component.

Problem

You have a design element that is used repeatedly in your site. Rather than copy and paste the markup everywhere, you want to make a component that handles the layout part for you. With the contents removed, it looks like this:

Show card layout graphically

How can we make a component that supports inserting content in multiple places? Something like this:

Show card insert areas

And what if the inserted content is complex, like HTML markup and not just simple strings?

Solution

A feature was introduced in LiveView v0.17 called "slots". The idea and inspiration for this comes from Javascript front-end frameworks like Vue.js.

The idea is pretty simple. We want to "slot in" content at different places in a component. If we have multiple places that take content, then we need to name the slot so we can specify which content is destined for which slot. This becomes clearer as we work through an example.

Let's start with the basic default slot that we've had for some time. Here we have a custom component called basic_card and pass content into it.

<.basic_card>
  Default content.
</.basic_card>

For this example, it should render like this:

Show card with default content

Let's see what the basic_card function looks like that makes this work. The CSS styling comes from TailwindCSS.

def basic_card(assigns) do
  ~H"""
  <div class="rounded-lg border border-gray-300 w-full shadow-sm">
    <div class="px-3 py-2">
      <%= render_slot(@inner_block) %>
    </div>
  </div>
  """
end

The key part here is the render_slot(@inner_block) function call. This renders the string "Default content." because it's passed in as the @inner_block. Meaning it's the blob-of-stuff-we-included-but-didn't-specify-a-slot-name that ends up as the default @inner_block.

But our design element includes a header, what should the template look like to pass in content for the header?

<.basic_card>
  <:header>
    Header Content
  </:header>

  Default content.
</.basic_card>

Here we use a colon (:) specify a "named slot" called header. Writing that in a template looks like this <:header></:header>

Whatever content we put between those tags is available in the function assigns as @header. Let's look at how the basic_card function changes to render the header.

def basic_card(assigns) do
  ~H"""
  <div class="rounded-lg border border-gray-300 w-full shadow-sm">
    <div class="rounded-t-lg bg-gray-100 px-3 py-2 font-medium border-b border-gray-300">
      <%= render_slot(@header) %>
    </div>
    <div class="px-3 py-2">
      <%= render_slot(@inner_block) %>
    </div>
  </div>
  """
end

This now renders like this:

Show card with header and default content

Yay! Looking good!

But if we play around with it, we'll see that if we don't include a <:header></:header> slot in the template, it blows up!

We can fix this! We can make the <:header></:header> slot optional. Doing this also reveals an interesting thing... a slot's content is actually a list.

We'll make a couple changes and talk through it next.

def basic_card(assigns) do
  assigns = assign_new(assigns, :header, fn -> [] end)

  ~H"""
  <div class="rounded-lg border border-gray-300 w-full shadow-sm ">
    <%= for header <- @header do %>
      <div class="rounded-t-lg bg-gray-100 px-3 py-2 font-medium border-b border-gray-300">
        <%= render_slot(header) %>
      </div>
    <% end %>
    <div class="px-3 py-2">
      <%= render_slot(@inner_block) %>
    </div>
  </div>
  """
end

We made the following two changes:

1) We used assign_new/3 to add :header to the assigns if it's not already there. If the key is missing, the 3rd argument, an anonymous function, is executed and the result is used for the value. In this case, it gets set to an empty list [].

This means our assigns will always have a @header and it is a list.

2) We use a for comprehension to render the header: for header <- @header do. If the :header list is empty, nothing gets rendered!

Setting a default empty list for the header and using a for comprehension to render it effectively makes our header slot optional!

Our simple header example could have just passed the contents as an attribute. The template would look like this:

<.basic_card header="Header Content">
  Default content.
</.basic_card>

Why is it better to use a slot? Because we can pass in complex markup instead of simple strings. Here's an example of more complex header content:

<.basic_card>
 <:header>
   <div class="flex items-center space-x-3">
    <div class="flex-shrink-0">
     <i class="fas fa-star text-2xl text-center text-gray-600 h-8 w-8" />
    </div>
    <div class="flex-1 min-w-0">
      <p class="text-sm font-medium text-gray-900">
        Header Text
      </p>
    </div>
   </div>
 </:header>

  <p>
    Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    sed do eiusmod tempor incididunt ut labore et dolore magna
    aliqua. Ut enim ad minim veniam, quis nostrud exercitation
    ullamco laboris nisi ut aliquip ex ea commodo consequat.
  </p>
</.basic_card>

This is using FontAwesome for the icon. It looks like this:

Show card with complex header

All that extra header markup starts to look messy, but if we find we keep using that complex header pattern in our app, then it can become a component as well. Even still, notice that all the markup noise is the exception instead of the norm.

This is where slots become really powerful. Slots let us insert complex content into multiple places inside of a component.

Discussion

Slots works really well when creating common components for our application. The components don't have to be 100% configurable, they just need to meet the our needs today. That's a great place to start and we can grow from there.

Slots can do even more than we looked at here. This was an example of how slots make common UI patterns easy to reuse in our applications. Check out this more advanced post that even covers passing arguments into a named slot as well.