Client-Side Tabs in LiveView with JS Commands

Image by Annie Ruygt

There are some things it really does make sense for our LiveView to do without calling home. Simple things that the browser doesn’t need help with. Things we’d like to see happen instantly, like hiding a modal—maybe even with a transition animation.

With the Phoenix.LiveView.JS module, we have an assortment of tidy, composable JS utility commands to easily carry out common client-side tasks without breaking into custom JavaScript.

Most LiveView JS commands do what can be summed up as manipulating HTML element attributes, with two exceptions. We can dispatch an event to a DOM element using JS.dispatch. And beyond purely client-side operations, JS.push pushes an event to the server, but with extended options to “customize targets, loading states, and additional payload values.”

The most excellent thing about LiveView JS commands may be that their work persists across DOM patches: when an update comes down the line from the server, the JS commands are replayed locally so our local changes don’t get wiped out.

Today we’re going to use a practical example to demonstrate a selection of these: JS.show, JS.hide, JS.remove_class, and JS.add_class.

The problem

We’re building a set of tabs, each with its own HTML content to display. Making a tabbed UI work boils down to showing stuff, hiding other stuff, and making one of the tabs look different so we know which one the displayed content belongs to.

None of that needs any information from the server, so it would be great if we didn’t have to push events to it and wait for it to respond.

The solution

We can build our tab functionality fully client-side, with a few LiveView JS commands. When a user clicks on a tab, we will:

  • Show the content that corresponds to that tab, using JS.show
  • Hide the content belonging to the other tabs, using JS.hide
  • Indicate which tab is now active by setting classes on the tab elements with JS.remove_class and JS.add_class (and styling them accordingly)

Laying the foundation

Planning ahead a bit: the utility functions we want to use all work by changing attributes on elements. remove_class and add_class are self-explanatory. JS.hide and JS.show give an element an inline style property of display: none; and (by default) display: block; respectively.

So let’s build the basis for the UI with that in mind.

<div class="container">
  <div class="tab_header">
    <ul>
      <li class="tab_option">
        <a id="tab1" class="tab active-tab"> Tab one </a>
      </li>
      <li class="tab_option">
        <a id="tab2" class="tab"> Tab two </a>
      </li>
      <li class="tab_option">
        <a id="tab3" class="tab"> Tab three </a>
      </li>
    </ul>
  </div>
  <div id="content" class="tab_body">
    <div id="content_tab_1" class="tab-content">
      <img src={ Routes.static_path(@socket, "/images/1.webp") }>
    </div>

    <div id="content_tab_2" class="tab-content hidden">
      <img src={ Routes.static_path(@socket, "/images/2.webp") }>
    </div>

    <div id="content_tab_3" class="tab-content hidden">
      <img src={ Routes.static_path(@socket, "/images/3.webp") }>
    </div>
  </div>
</div>

The tabs at the top are a styled unordered list. In the #content div below the tabs, we have the content for all the tab options, in elements with id attributes "content_tab_1", "content_tab_2", and "content_tab_3". We’re using the class "hidden" to hide two of the three.

We’re fans of Tailwind CSS, where an element with class="hidden" gets the property display: none;. If we’re not using Tailwind, we can add that CSS rule ourselves. This sets the default visibility of each piece of content.

Here’s what the result could look like:

A row of tabs labeled "Tab one" through "Tab three", with an image beneath; "Tab one" has different styling.

Yay! We have constructed something that looks like a tabbed interface when the page loads. It looks like one tab is active, thanks to some styling on the active-tab class. Only one of the content options is displayed.

But the content doesn’t actually change if a user clicks on a different tab.

JS.show

To change that, we’ll add a phx-click binding to each tab, invoking JS.show for the corresponding content-containing element.

<li class="tab_option">
  <a id="tab1" class="tab active-tab" 
    phx-click={JS.show(
      to:"#content_tab_1",
      transition: {"ease-out duration-300", "opacity-0", "opacity-100"},
      time: 300
    )}
  > Tab one </a>
</li>

The to option takes a DOM identifier for the element we want to target. Above, we’re targeting the #content_tab_1 element when the #tab1 element is clicked.

Since JS commands and DOM patches are coordinated, we can use CSS transition animations and not worry about them being interrupted if the server happens to send an update. (Fun fact: there is also a JS.transition command in case you want a transition animation by itself. )

We’re giving the transition option a 3-tuple of classes: "ease-out" and "duration-300" have CSS styling to provide the properties for the transition (in this case transition-timing-function and transition-duration ). Class "opacity-0" provides styling for the start of the transition, and "opacity-100" for the end.

Yes, we’re using Tailwind classes again. You can also customize the transition by using your own classes, as long as the app’s stylesheet provides rules for them.

Finally, there’s a time option. It limits the time taken for the transition, and it defaults to 200ms. Here we’re using a transition that takes 300ms, so we have to adjust time to 300ms to ensure we don’t get any weird behaviour.

If we do this for each tab, clicking on a tab makes its corresponding content div visible.

Clearly we’re not done yet. We still need to hide the content of the other tab(s). This is where JS.hide comes into play.

JS.hide

JS.hide hides the HTML elements we choose by giving them the style property display: none;. Let’s make some changes to our code.

We could just stick a JS.hide command before JS.show in each tab’s phx-click binding. But we need the same pattern in all three tabs, so we may as well make a private function to both show the active content and hide the content of the remaining tabs.

We’ll call it show_active_content/2:

defp show_active_content(js \\ %JS{}, to) do
  js
  |> JS.hide(to: "div.tab_content")
  |> JS.show(to: to)
end

First, show_active_content does a JS.hide on all the divs that have class tab_content; then it JS.shows the element whose DOM identifier is specified in the to argument.

For simplicity, this snippet doesn’t show the transition option, but we would use it in just the same way as we did with JS.show above. We can also add a transition to JS.hide in the same way.

A note here about the js argument to this function. The JS commands inside are composable: that is, we can apply them in a series, piping the output of one into the input of the next. Our optional js argument makes show_active_content composable as well. If there’s no input from a previous command, js is just an empty struct %JS{} .

Let’s see how to use our function:

<li class="tab_option">
  <a id="tab1" class="tab active-tab" 
    phx-click={show_active_content("#content_tab_1")}
  > Tab one </a>
</li>
<li class="tab_option">
  <a id="tab2" class="tab" 
    phx-click={show_active_content("#content_tab_2")}
  > Tab two </a>
</li>
<li class="tab_option">
  <a id="tab3" class="tab" 
    phx-click={show_active_content("#content_tab_3")}
  > Tab three </a>
</li>

Each of our tab options will pass the identifier for its own content in the to argument of show_active_content, so that after JS.hide hides all the content components, the content for just the desired tab gets unhidden.

JS.remove_class and JS.add_class

So far we can show the content of any tab with a click, but right now, the first tab always looks like it’s the active one. That’s because it has the class "active-tab" and we have some CSS behind the scenes to make it look special.

So all we have to do is remove the class "active-tab" from the tab element that has it, and add it to the tab that’s been clicked. For this we define a new composable function:

defp set_active_tab(js \\ %JS{}, tab) do
  js
  |> JS.remove_class("active-tab", to: "a.active-tab")
  |> JS.add_class("active-tab", to: tab)
end

Here, we’re literally using the class that we want to remove in the DOM identifier to pass to JS.remove_class. Then JS.add_class adds the "active-tab" class to the tab whose identifier was sent in the tab parameter.

Incidentally, you can add a transition effect to both JS.remove_class and JS.add_class just as with JS.show and JS.hide.

Now let’s see how to use our function:

<li class="tab_option">
  <a id="tab1" class="tab active-tab" 
    phx-click={
      set_active_tab("#tab1") |> show_active_content("#content_tab_1")
      }
  > Tab one </a>
</li>
<li class="tab_option">
  <a id="tab2" class="tab" 
    phx-click={
      set_active_tab("#tab2") |> show_active_content("#content_tab_2")
      }
  > Tab two </a>
</li>
<li class="tab_option">
  <a id="tab3" class="tab" 
    phx-click={
      set_active_tab("#tab3") |> show_active_content("#content_tab_3")
      }
  > Tab three </a>
</li>

Since we made both functions composable, we can string them together with the pipe operator.

There we have it: a working tab bar.