Bootstrap 5 and Phoenix LiveView


Even though a large part of the Phoenix community seem to embrace Tailwind, there are still a lot that prefer Bootstrap CSS framework. And with Boostrap 5 just around the corner, I think its interesting to see how it works with latest Phoenix and Phoenix LiveView.

So, in this tutorial, I will show you how to install Bootstrap 5 in a Phoenix LiveView app.

Step 1 - Install the assets

At the time of writing this, Bootstrap 5 is in v5.0.0-beta1. I dont think anything will change but make sure to see in the docs.

I prefer to install it with yarn instead of pointing to a CDN or download js-files from their site. Note that Bootstrap requires popperjs for some of its components so I need to install that as well.

cd assets
yarn add -D postcss postcss-loader autoprefixer
yarn add @popperjs/core bootstrap@next

Now when the packages are installed, I need to configure both Webpack and PostCss.

// assets/webpack.config.js

use: [
  'postcss-loader', // Add this

Create or edit the postcss config file to add autoprefixer.

// assets/postcss.config.js

module.exports = {
  plugins: [

To get started, import the Bootstrap js in the app js file:

// assets/js/app.js

import "bootstrap"

Besides the js, I also need to import the css. Remove the phoenix.css import and add the bootstrap import. So, in the top, add:

// assets/css/app.scss

@import "~bootstrap/scss/bootstrap";

Last thing here is to modify the template and use a classic bootstrap layout. Since this is a LiveView app, I need to edit the root.html.leex

<!DOCTYPE html>
<html lang="en">
    <!-- keep the original header -->
    <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">Navbar</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>

        <div class="collapse navbar-collapse" id="navbarsExampleDefault">
          <ul class="navbar-nav me-auto mb-2 mb-md-0">
            <li class="nav-item">
              <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
            <li class="nav-item dropdown">
              <a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
              <ul class="dropdown-menu" aria-labelledby="dropdown01">
                <li><a class="dropdown-item" href="#">Action</a></li>
                <li><a class="dropdown-item" href="#">Another action</a></li>
                <li><a class="dropdown-item" href="#">Something else here</a></li>
          <form class="d-flex">
            <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
            <button class="btn btn-outline-success" type="submit">Search</button>

    <%= @inner_content %>


When I start the server and visit the page in the browser I see that the bootstrap styleshhet was successfully loaded. Result:

And I can see that the imported javascipt is working by clicking the dropdown:

Great success. Or at least a step forward. Now I am curious on how this works with a Phoenix generated LiveView CRUD.

Step 2 - Add bootstrap to a liveview resource

In the boostrap

mix phx.gen.live Products Product products name description:text

Add the routes to the route file and then run migrations with:

mix ecto.migrate

2.1 Update the form

From the start, the form doesnt look good at all.

I need to add the classes for forms that I can find in the Bootstrap documentation.

<h2><%= @title %></h2>

<%= f = form_for @changeset, "#",
  id: "product-form",
  phx_target: @myself,
  phx_change: "validate",
  phx_submit: "save" %>

  <div class="mb-3">
    <%= label f, :name, class: "form-label" %>
    <%= text_input f, :name, class: "form-control" %>
    <%= error_tag f, :name %>

  <div class="mb-3">
    <%= label f, :description, class: "form-label" %>
    <%= textarea f, :description, class: "form-control" %>
    <%= error_tag f, :description %>

  <div class="mb-3">
    <%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-primary" %>

With these small changes it looks much better.

2.2 Update the modal

The next step for me is to update the modal itself. My goal is to make the modal look and feel more like the Bootstrap modal. First, I need to clear all the styles that comes with phoenix and affects both the modal and the form.

Keep only these styles in the app.scss

/* assets/css/app.scss */ 

@import "~bootstrap/scss/bootstrap";
@import "../node_modules/nprogress/nprogress.css";

body { /* Add this */
  padding-top: 6rem;

/* LiveView specific classes for your customizations */
.phx-no-feedback .invalid-feedback {
  display: none;

.phx-click-loading {
  opacity: 0.5;
  transition: opacity 1s ease-out;

  cursor: wait;

.phx-disconnected *{
  pointer-events: none;

.alert:empty {
  display: none;

After I generated the live views for the products, I also got the modal_component.ex. Inside the render function. I want to update with the markup for a bootstrap model.

<div id="<%= @id %>" class="modal fade phx-modal"
  phx-target="#<%= @id %>"

  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Modal title</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      <div class="modal-body">
        <%= live_component @socket, @component, @opts %>

NOTE that I added a phx-hook="BsModal". The reason is that I ultimately want to open the modal in javascript land. So I need to fire of a hook.

Here is code for the javascript hook. I import the Modal from Bootstrap and create a new modal instance and open that. I am also adding a eventlistener for the modal to push the close event to the LiveView ModalComponent. With that in place, I can close the modal with an animationeffect and still let LiveView know about the state of the modal.

// assets/js/bs_modal.js
import {Modal} from "bootstrap"

export const BsModal = {
  mounted() {
    const modal = new Modal(this.el)

    this.el.addEventListener('hidden.bs.modal', event => {
      this.pushEventTo(`#${this.el.getAttribute('id')}`,'close', {})

However, this doesnt work properly unless I remove the LiveView functionality to close the modal. So I need to remove these three lines from the template.

<!-- lib/bootstrap_web/live/modal_component.ex -->

There is still one problem to solve with the modal. That is if I actually submit the modal. Then it gets closed with the bootstrap modal backdrop still in the DOM.

// assets/js/bs_modal.js
export const BsModal = {
  mounted() {
    // ... other code
    // Add this below

    this.el.querySelector('form').addEventListener('submit', event => {
      const backdrop = document.querySelector('.modal-backdrop')
      if (backdrop) backdrop.parentElement.removeChild(backdrop)

The drawback here is that there is no animated exit when submitting the form. For the moment, that is a tradeoff I can live with.

2.3 Form validation

With this approach, I want to prevent the form from being submitten while invalid. This works best with the javascript hook I wrote above. And I usually solves this by disabling the submitbutton if the form is not valid. So I need to add this code disabled: !@changeset.valid? to the submit button.

In the form, I add:

<!-- lib/bootstrap_web/live/product_live/form_component.html.leex -->
<!-- .. Other code -->
<%= submit "Save", phx_disable_with: "Saving...", disabled: !@changeset.valid?, class: "btn btn-primary" %>

Next step is that I want to use the Boostrap validation classes to display the entire form field with a red border. And to accomplish that, I will add another validation hook so as soon as the invalid message is shown, I will fire off the hook. In the error_tag I need to add both id and the phx_hook.

# lib/bootstrap_web/views/error_helpers.ex
def error_tag(form, field) do
  Enum.map(Keyword.get_values(form.errors, field), fn error ->
    content_tag(:div, translate_error(error),
      class: "invalid-feedback",
      id: "validation-#{input_id(form, field)}", # Add this line
      phx_hook: "BsFieldValidation",             # And add this line
      phx_feedback_for: input_id(form, field)

Basically, the idea is that I need to find the form field that the error message is referring to and add the is-invalid-class to it.

export const BsFieldValidation = {
  mounted() {
    const message = this.el
    if (message.classList.contains('phx-no-feedback')) return // Not the field in focus

    const field = document.getElementById(message.getAttribute('phx-feedback-for'))


Even though I usually grab Tailwind these days, I think that Bootstrap is still a great CSS framwwork and with their new revamped javascript that no longer require Jquery, it seems great for combining with Phoenix LiveView.

Phoenix Boilerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Learn More

Related Tutorials

Published 23 Sep - 2020

Setup Stripe with Phoenix LiveView


In this tutorial, I will go through how I setup Stripe payments with Phoenix and LiveView to make your app prepared for accepting payments. The tuto..

Published 13 May

How to create a custom select with Alpine JS and Phoenix LiveView


In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoeni..