Update v5.0 - Preline MCP, AI Prompts, Animated Icons and more. Visit Changelog

Phoenix LiveView

Using Preline UI with Phoenix LiveView

A practical guide to wiring Preline UI into Phoenix LiveView across esbuild and Vite, including reinitializing after live updates with a Phoenix LiveView Hook.

Phoenix LiveView and Preline UI fit together cleanly once you separate the two layers. Phoenix is an Elixir/OTP web framework that renders HTML on the server with HEEx templates and pushes live updates over a WebSocket, patching the DOM in place instead of reloading the page. Frontend assets are bundled by esbuild, which ships built into Phoenix, or by Vite if you prefer that build step. Preline UI is the client-side behavior layer that reads that HTML in the browser and wires up the interaction.

The detail that matters in LiveView is that each diff can replace DOM nodes through a morphdom-style patch. Preline UI attaches behavior to the nodes that exist when it initializes, so after LiveView patches the page you have to run autoInit again. This guide covers where to initialize from assets/js/app.js under esbuild, how the same idea maps to Vite, how to reinitialize after LiveView mounts and patches an element with a Phoenix LiveView Hook, how to use Preline UI markup inside .html.heex templates, and how to avoid stale plugin references as the page updates.

Start with the Phoenix LiveView mental model

Preline UI is a DOM-driven Tailwind CSS component system. Phoenix produces the markup, and Preline UI attaches behavior to that markup once it exists in the browser. That is why the same dropdown, overlay, tabs, tooltip, or select markup can move between Phoenix, plain HTML, React, Vue, Svelte, Rails, Laravel Livewire, and similar stacks.

The detail that matters in Phoenix is timing. HEEx renders server-side HTML, so Preline UI cannot initialize from inside a .html.heex file. It needs a client script that your bundler ships, the assets/js/app.js entry that esbuild builds by default, or a Vite entrypoint. And because LiveView patches the DOM on each diff rather than doing a full reload, a single page-load scan is not enough. Everything below is about running initialization at the right moment for each kind of update.

Initialize Preline UI from the esbuild entry

esbuild is the default Phoenix frontend bundler and ships built into the framework. It builds assets/js/app.js and resolves npm packages from assets/node_modules, so install Preline UI inside the assets directory and import it from that entry. The full Phoenix LiveView installation covers that setup step by step.

Terminal
                        
                          cd assets
                          npm install preline
                        
                      

With the package installed, import Preline UI from assets/js/app.js alongside your LiveView setup. The preline auto entry exposes the window.HSStaticMethods global, so import it once and run autoInit when the DOM is ready. The next section adds the LiveView hooks that re-run it after live updates.

assets/js/app.js
                        
                          import "phoenix_html"
                          import { Socket } from "phoenix"
                          import { LiveSocket } from "phoenix_live_view"

                          // Preline UI
                          import "preline"

                          // First full page load
                          document.addEventListener("DOMContentLoaded", () => {
                            window.HSStaticMethods.autoInit();
                          });
                        
                      

Bring the Tailwind CSS entry that builds Preline UI styling in through your stylesheet, for example @import 'preline/variants.css' in assets/css/app.css, so the page ships both the markup behavior and the styles. Also add an @source pointed at your app's lib/*_web directory: Tailwind's automatic content detection does not reach outside assets/, so without it your own HEEx and component classes will not compile, only Preline UI's. See the full app.css in the installation guide for the complete setup. The next section covers the LiveView hooks that re-run initialization after the page is patched.

Use Vite when you want a different build step

If you prefer Vite over esbuild, the vite_phx setup or a manual Vite watcher wires Vite into the Phoenix asset pipeline. You install Preline UI from npm the same way and import it from assets/js/app.js, but Vite bundles ES modules directly, so preline/non-auto becomes a good default: nothing runs until your code decides the DOM is ready.

assets/js/app.js
                        
                          import "../css/app.css";
                          import { Socket } from "phoenix"
                          import { LiveSocket } from "phoenix_live_view"
                          import { HSStaticMethods } from "preline/non-auto";

                          document.addEventListener("DOMContentLoaded", () => {
                            HSStaticMethods.autoInit();
                          });
                        
                      

The rest of this guide applies the same way to both bundlers: esbuild exposes window.HSStaticMethods from the preline auto entry, while Vite exposes the plugin classes and HSStaticMethods through bundled module imports from preline/non-auto. The LiveView re-init hooks in the next section are identical for both.

Reinitialize after LiveView updates

This is the key step for Phoenix LiveView. LiveView changes the page without a full reload: live navigation through live_redirect/live_patch, and DOM patches that morph markup in place after each diff from the server. Both can add or replace nodes that Preline UI needs to initialize. Rather than a page-wide DOM event listener, use a Phoenix LiveView Hook, the mechanism LiveView itself documents for running JavaScript in response to an element being mounted or patched, and it scopes the re-init to exactly the elements that need it.

A hook's mounted() runs once when its element first enters the DOM, and updated() runs every time LiveView patches that element afterward. Define one generic hook that calls autoInit from both, register it on the LiveSocket, and attach it with phx-hook to any container that renders Preline UI markup.

assets/js/app.js
                        
                          import { Socket } from "phoenix"
                          import { LiveSocket } from "phoenix_live_view"
                          import "preline"

                          const Hooks = {
                            PrelineInit: {
                              mounted() { window.HSStaticMethods?.autoInit() },
                              updated() { window.HSStaticMethods?.autoInit() },
                            },
                          }

                          const csrfToken = document
                            .querySelector("meta[name='csrf-token']")
                            .getAttribute("content");

                          const liveSocket = new LiveSocket("/live", Socket, {
                            params: { _csrf_token: csrfToken },
                            hooks: Hooks
                          });

                          liveSocket.connect();
                        
                      
lib/my_app_web/components/actions_menu.html.heex
                        
                          <div id="actions-menu" phx-hook="PrelineInit" class="hs-dropdown relative inline-flex">
                            ...
                          </div>
                        
                      

LiveView requires a unique id on every element carrying a phx-hook, so give the container one. autoInit skips elements that already have plugin instances, so mounted() and updated() calling it back to back never double-initializes. Under Vite, swap import "preline" and window.HSStaticMethods for import { HSStaticMethods } from "preline/non-auto" and call HSStaticMethods.autoInit() from the same two hook callbacks.

That same "skips elements that already have plugin instances" behavior is also why the hook alone is not enough for a plugin holding runtime state, such as an open accordion panel, a selected Datepicker date, a dragged Range Slider handle, or Datatable's own JS-built pagination buttons. A LiveView diff can reset an element's attributes back to its server-rendered markup even when the diff is for a completely unrelated part of the page, the element itself is never replaced, and updated() never fires for it, which silently discards state that only ever existed in the live DOM (an inline style Preline UI set, a class it toggled, markup its JS inserted). Because the element still has a registered plugin instance, re-running autoInit afterward does not detect or repair the loss. The fix is not a JS-side one: keep the stable id and add phx-update="ignore" alongside phx-hook in your HEEx so LiveView never reconciles that subtree's DOM after the first render. See Use Preline UI markup in HEEx templates below.

Scope a plugin to a Phoenix LiveView Hook

The generic PrelineInit hook above covers most markup: it calls autoInit, which figures out which plugin belongs to which element on its own. Scope a hook to one specific plugin class instead when a widget needs lifecycle handling autoInit has no equivalent for, most commonly explicit cleanup via destroyed(), which runs when LiveView removes the element from the DOM. This maps directly onto initializing and tearing down one Preline UI instance, the same way a Stimulus controller does in Rails.

assets/js/app.js
                        
                          import { HSDropdown } from "preline/non-auto";

                          const Hooks = {
                            PrelineInit: {
                              mounted() { window.HSStaticMethods?.autoInit() },
                              updated() { window.HSStaticMethods?.autoInit() },
                            },
                            Dropdown: {
                              mounted() {
                                if (!HSDropdown.getInstance(this.el)) {
                                  new HSDropdown(this.el);
                                }
                              },
                              destroyed() {
                                const instance = HSDropdown.getInstance(this.el, true);
                                instance?.element.destroy();
                              }
                            },
                          };

                          // ...merge Hooks into the same LiveSocket({ hooks: Hooks }) call
                          // shown above.
                        
                      

Attach phx-hook="Dropdown" to the plugin's root element in your HEEx, with the same unique-id requirement as any hooked element, and Phoenix takes over the timing for that node. The getInstance guard keeps mounted() from double-initializing a node that a page-level PrelineInit hook elsewhere already handled.

lib/my_app_web/components/actions_menu.html.heex
                        
                          <div id="actions-menu" phx-hook="Dropdown" class="hs-dropdown relative inline-flex">
                            ...
                          </div>
                        
                      

Choose imports by the level of control you need

For a full Preline UI installation under Vite, preline/non-auto is the best default. It gives you HSStaticMethods and the plugin classes without depending on plugin auto-entry page-load timing, which matters once LiveView controls when the DOM changes.

assets/js/preline.js
                        
                          import { HSStaticMethods } from "preline/non-auto";

                          HSStaticMethods.autoInit(["dropdown", "overlay"]);
                          HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
                        
                      

If a page only needs the class for one plugin from the full package, import that class from preline/non-auto. This keeps the example aligned with the package's declared surface while still letting your code decide when to initialize.

assets/js/dropdown.js
                        
                          import { HSDropdown } from "preline/non-auto";

                          HSDropdown.autoInit();
                        
                      

Under esbuild's default setup the preline auto entry exposes the same plugin classes and HSStaticMethods through window.HSStaticMethods instead.

Single plugin packages keep small Phoenix surfaces focused

Not every Phoenix app needs the whole library. Preline UI plugins can also be consumed from single-plugin dependencies when those packages are available in your dependency set, for example @preline/dropdown, @preline/overlay, @preline/select, or @preline/range-slider. This keeps the integration focused when only a small part of the page needs Preline UI behavior.

Terminal
                        
                          cd assets
                          npm install @preline/dropdown
                        
                      
assets/js/app.js
                        
                          import HSDropdown from "@preline/dropdown/non-auto";

                          const Hooks = {
                            PrelineInit: {
                              mounted() { HSDropdown.autoInit() },
                              updated() { HSDropdown.autoInit() },
                            },
                          };

                          // ...merge Hooks into your LiveSocket({ hooks: Hooks }) call.
                        
                      

In that single-package setup, the auto entry is import "@preline/dropdown". Use it only for static pages with no LiveView updates. As soon as live navigation or DOM patches are involved, the /non-auto entry plus a phx-hook keeps initialization aligned with the timing you control, the same as the full-package PrelineInit hook above.

Use Preline UI markup in HEEx templates

HEEx is HTML-in-Elixir, so Preline UI markup drops straight into a .html.heex file with the same Tailwind CSS classes you would use anywhere else. The esbuild or Vite entry your root layout loads is what attaches the behavior once the page reaches the browser.

lib/my_app_web/components/actions_menu.html.heex
                        
                          <div class="hs-dropdown relative inline-flex">
                            <button id="hs-dropdown-example" type="button" class="hs-dropdown-toggle ..." aria-haspopup="menu" aria-expanded="false" aria-label="Dropdown">
                              Actions
                            </button>

                            <div class="hs-dropdown-menu ... hidden" role="menu" aria-orientation="vertical" aria-labelledby="hs-dropdown-example">
                              ...
                            </div>
                          </div>
                        
                      

Make sure your root layout loads the entry that ships Preline UI, for example <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}></script>, so every page that renders Preline UI markup also ships the script that initializes it. When a Preline UI widget holds open or selected state you do not want LiveView to reset on patch, give it a stable id and mark it with phx-update="ignore" so LiveView skips patching that subtree.

lib/my_app_web/components/actions_menu.html.heex
                        
                          <div id="actions-menu" phx-update="ignore">
                            <div class="hs-dropdown relative inline-flex">
                              ...
                            </div>
                          </div>
                        
                      

Use manual instances when you own a specific node

autoInit is good for page-level markup. A manual instance is better when one specific node needs explicit control, especially when it is paired with phx-update="ignore" so LiveView does not patch it away. Create the instance from a phx-hook and guard it with getInstance so updated() firing again does not double-initialize the same node.

assets/js/app.js
                        
                          import { HSDropdown } from "preline/non-auto";

                          function initUserMenu(el) {
                            if (el && !HSDropdown.getInstance(el)) {
                              new HSDropdown(el);
                            }
                          }

                          const Hooks = {
                            UserMenu: {
                              mounted() { initUserMenu(this.el) },
                              updated() { initUserMenu(this.el) },
                            },
                          };

                          // ...merge Hooks into your LiveSocket({ hooks: Hooks }) call.
                        
                      

The getInstance guard keeps a single instance per node across patches. If LiveView is allowed to patch the node, prefer a plain PrelineInit hook over a manual instance, since the element it points at can be replaced; reach for a manual instance when you specifically need the instance reference back, for example to call .close() or .destroy() on it elsewhere in your code.

Cleanup matters because Preline UI keeps registries

Preline UI stores plugin instances in internal collections such as window.$hsDropdownCollection. That registry lets plugins coordinate in plain HTML, Phoenix, and other environments without framework context.

A full page load resets the registry, but LiveView patches replace nodes in place. When a patch removes initialized markup, its instance can linger in the collection as a stale reference. When you intentionally swap out a region of Preline UI markup, clean the relevant collection before re-running autoInit so the registry only holds live nodes.

Cleanup
                        
                          HSStaticMethods.cleanCollection("dropdown");
                          HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
                        
                      

Optional dependencies only matter for the plugins that use them

Most core plugins do not use a third-party library at all. Dropdowns, overlays, tooltips, popovers, tabs, and similar components use plain JavaScript. Positioning behavior uses @floating-ui/dom. A handful of plugins wrap a third-party library instead, and each one checks for its own dependency on window before it will initialize:

  • Datatable needs window.jQuery and window.DataTable, because datatables.net depends on jQuery.
  • Datepicker needs window.VanillaCalendarPro and window._ (lodash). The lodash dependency is easy to miss since it is not obvious from the plugin's name, but its internal options-merging calls _.merge/_.mergeWith directly.
  • Range Slider needs window.noUiSlider. Preline UI remains responsible for the Tailwind CSS markup and behavior wrapper, so you do not need to bring in noUiSlider's own CSS just to make Preline UI styling work.
  • File Upload needs window.Dropzone and window._ (lodash) together.

If a required global is missing, that plugin simply does not initialize; it does not affect dropdowns, overlays, tabs, tooltips, or any other plugin.

Under the esbuild auto entry, set each window global from the corresponding npm package before importing preline. This is one place where import order genuinely matters and is easy to get subtly wrong: a static import placed above import "preline" looks like it runs first, but ES module imports are hoisted, so every statically imported module, including preline itself, finishes evaluating before any of the file's own top-level statements run. The auto entry decides once, at that evaluation, whether each dependency-gated plugin is available, so a same-file assignment like window.DataTable = DataTable is too late even though it appears earlier in the source. Dynamic import() calls do not have this problem, since each one is a real asynchronous step that runs when your code reaches it, not when the module graph loads:

assets/js/app.js
                        
                          async function initPreline() {
                            const { default: $ } = await import("jquery");
                            window.$ = $;
                            window.jQuery = $;

                            const { default: DataTable } = await import("datatables.net");
                            window.DataTable = DataTable;

                            const { default: _ } = await import("lodash");
                            window._ = _;

                            const { Calendar } = await import("vanilla-calendar-pro");
                            window.VanillaCalendarPro = Calendar;

                            const { default: noUiSlider } = await import("nouislider");
                            window.noUiSlider = noUiSlider;

                            const { default: Dropzone } = await import("dropzone");
                            window.Dropzone = Dropzone;

                            await import("preline");
                            window.HSStaticMethods?.autoInit();
                          }

                          if (document.readyState === "loading") {
                            document.addEventListener("DOMContentLoaded", initPreline);
                          } else {
                            initPreline();
                          }
                        
                      

Only import the packages the page actually uses; the list above shows all five together for illustration. This initPreline function replaces the plain import "preline" shown in the PrelineInit hook setup above as soon as the page needs one of these plugins; the PrelineInit hook itself does not change, it just ends up calling autoInit after this async setup has run instead of after a synchronous import. Under Vite, this ordering concern does not apply the same way, since preline/non-auto exposes plugin classes without an eager availability check at import time, so set the relevant window global before you call that plugin's own autoInit().

The practical checklist

  • Initialize Preline UI from your bundler entry such as assets/js/app.js, not from a .html.heex template.
  • Under esbuild, import the preline auto entry and call window.HSStaticMethods.autoInit(); under Vite, import from preline/non-auto and run HSStaticMethods.autoInit().
  • Reinitialize with a Phoenix LiveView Hook, not a page-wide DOM event listener: a generic PrelineInit hook whose mounted() and updated() both call autoInit, attached with phx-hook to any element rendering Preline UI markup.
  • Scope a hook to one specific plugin class instead, with mounted() and destroyed(), when a widget needs explicit cleanup autoInit has no equivalent for.
  • Give stateful Preline UI widgets a stable id and mark them with phx-update="ignore" when you do not want LiveView to reset them on patch.
  • Guard manual instances with getInstance, and call cleanCollection when you intentionally remove a region of initialized markup.
  • Install optional third-party dependencies only for the plugins that need them: jquery + datatables.net for Datatable, vanilla-calendar-pro + lodash for Datepicker, noUiSlider for Range Slider, or dropzone + lodash for File Upload.
  • Under the esbuild auto entry, set each dependency's window global with a dynamic import() before importing preline, not a static one: a static import above import "preline" still loses the race due to ES import hoisting.

© 2026 Preline Labs.