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

Ruby on Rails

Using Preline UI with Ruby on Rails

A practical guide to wiring Preline UI into Ruby on Rails across Importmap and Vite Ruby, including the Turbo Drive re-init hook for Hotwire and an optional Stimulus controller pattern.

Ruby on Rails and Preline UI fit together cleanly once you separate the two layers. Rails is a full-stack Ruby MVC framework that renders HTML on the server with ERB templates and manages frontend assets through Importmap with Propshaft, Vite Ruby, or jsbundling-rails. Preline UI is the client-side behavior layer that reads that HTML in the browser and wires up the interaction. The small amount of script your asset pipeline ships is what carries Preline UI across that server-to-client boundary.

This guide walks through the integration choices that matter in a real Rails project: where to run autoInit from an Importmap entry, how the same idea maps to Vite Ruby, how to keep Preline UI working when Turbo Drive swaps the DOM without a reload, how to scope a plugin to a Stimulus controller's lifecycle, how to use Preline UI markup inside .html.erb templates, and how to avoid stale plugin references as pages change.

Start with the Rails mental model

Preline UI is a DOM-driven Tailwind CSS component system. Rails 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 Rails, plain HTML, React, Vue, Svelte, Symfony, Laravel, and similar stacks.

The detail that matters in Rails is where the JavaScript runs. ERB templates render server-side HTML, so Preline UI cannot initialize from inside a .html.erb file. It needs a client script that your asset pipeline ships, either an Importmap entry such as app/javascript/application.js with Propshaft, or a Vite Ruby entrypoint. Rails 7+ ships Hotwire (Turbo and Stimulus) by default, so navigation is a partial DOM swap rather than a full reload, which means you have to re-run initialization after each Turbo visit. Everything below is about putting autoInit in the right place for each case.

Initialize Preline UI from the Importmap entry

Importmap with Propshaft is the default Rails 7+ frontend setup, and it ships ES modules over an importmap with no Node.js bundling. Importmap cannot serve files directly from node_modules, so the install pins the preline module and copies the bundled file into vendor/javascript/. The full Ruby on Rails installation covers that setup step by step.

config/importmap.rb
                        
                          pin "preline", to: "preline.js", preload: true
                        
                      

With the pin in place, initialize Preline UI from your Importmap entry, usually app/javascript/application.js. Because the pinned preline bundle exposes the window.HSStaticMethods global, import it once and run autoInit when the DOM is ready. Rails 7+ ships Turbo, so hook into turbo:load rather than DOMContentLoaded as your first-load hook.

app/javascript/application.js
                        
                          // ... other imports
                          import "preline";

                          document.addEventListener("turbo:load", () => {
                            window.HSStaticMethods.cleanCollection();
                            window.HSStaticMethods.autoInit();
                          });
                        
                      

Bring the Tailwind CSS entry that builds Preline UI styling in through your stylesheet, for example @import 'preline/variants.css' in app/assets/tailwind/application.css (the source file the tailwindcss-rails gem's build task actually compiles, not the similarly-named app/assets/stylesheets/application.css, which Propshaft serves uncompiled), so the page ships both the markup behavior and the styles. The next section covers why turbo:load is the right event for Rails, and the frame-level hook that goes with it.

Use Vite Ruby when you want a Node.js build step

If you prefer a bundler over Importmap, the vite_rails gem wires Vite into the Rails asset pipeline. With Vite Ruby you install Preline UI from npm and import it from a JavaScript entrypoint, usually app/javascript/entrypoints/application.js. Vite bundles that file, so module imports work as expected and preline/non-auto becomes a good default: nothing runs until your code decides the DOM is ready.

Terminal
                        
                          npm install preline
                        
                      
app/javascript/entrypoints/application.js
                        
                          import { HSStaticMethods } from "preline/non-auto";

                          document.addEventListener("turbo:load", () => {
                            HSStaticMethods.autoInit();
                          });
                        
                      

Load the entrypoint from your layout with <%= vite_javascript_tag 'application' %>. The rest of this guide applies the same way to both pipelines: Importmap exposes window.HSStaticMethods from the pinned bundle, while Vite exposes the plugin classes through bundled module imports from preline/non-auto.

Reinitialize on Turbo Drive navigation

This is the key step for Rails 7+, because Hotwire is on by default. Turbo Drive intercepts link clicks and form submissions and replaces the page <body> with a partial DOM swap instead of doing a full reload. Your entry script runs once, but DOMContentLoaded does not fire again on later Turbo visits, so Preline UI markup rendered after navigation never gets initialized. This is the same re-init problem you see in Symfony with Turbo and in Livewire, and the fix is the same shape: re-run autoInit after each navigation.

Hook into turbo:load instead of DOMContentLoaded. Turbo fires turbo:load on the initial page load and after every Turbo visit, so it doubles as your first-load hook and your re-init hook. Pair autoInit with cleanCollection here: Turbo Drive's default navigation replaces the whole <body> instead of reloading, so every visit leaves the previous page's plugin instances registered against DOM nodes that are already gone.

app/javascript/application.js
                        
                          import "preline";

                          // Fires on first load and after every Turbo visit
                          document.addEventListener("turbo:load", () => {
                            window.HSStaticMethods.cleanCollection();
                            window.HSStaticMethods.autoInit();
                          });
                        
                      

If you also render lazy <turbo-frame> regions, those load their own markup independently, so re-run autoInit after a frame loads as well. autoInit skips elements that already have plugin instances, so running it on both events keeps the whole page covered without double-initializing.

app/javascript/application.js
                        
                          // After a lazy Turbo Frame swaps in its content
                          document.addEventListener("turbo:frame-load", () => {
                            window.HSStaticMethods.autoInit();
                          });
                        
                      

Scope a plugin to a Stimulus controller

Page-level autoInit on turbo:load covers most markup, but Stimulus gives you finer control when a single widget needs explicit lifecycle handling. A Stimulus controller's connect() runs whenever its element enters the DOM, including after Turbo swaps and Turbo Frame loads, and disconnect() runs when the element leaves. That maps directly onto initializing and cleaning up one Preline UI instance.

app/javascript/controllers/dropdown_controller.js
                        
                          import { Controller } from "@hotwired/stimulus";
                          import { HSDropdown } from "preline/non-auto";

                          export default class extends Controller {
                            connect() {
                              if (!HSDropdown.getInstance(this.element)) {
                                new HSDropdown(this.element);
                              }
                            }

                            disconnect() {
                              const instance = HSDropdown.getInstance(this.element, true);
                              instance?.element.destroy();
                            }
                          }
                        
                      

Attach the controller to the plugin's root element in your ERB, and Stimulus takes over the timing for that node. Under Importmap, import the plugin class from the pinned preline bundle through window.HSStaticMethods if you prefer the global; under Vite Ruby, import it from preline/non-auto as shown. The getInstance guard keeps connect() from double-initializing a node that page-level autoInit already handled.

app/views/shared/_actions_menu.html.erb
                        
                          <div data-controller="dropdown" class="hs-dropdown relative inline-flex">
                            ...
                          </div>
                        
                      

Choose imports by the level of control you need

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

app/javascript/entrypoints/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.

app/javascript/entrypoints/dropdown.js
                        
                          import { HSDropdown } from "preline/non-auto";

                          HSDropdown.autoInit();
                        
                      

Under Importmap there is no bundler to tree-shake, so the pinned preline bundle exposes the same plugin classes and HSStaticMethods through window.HSStaticMethods instead.

Single plugin packages keep small Rails surfaces focused

Not every Rails 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
                        
                          npm install @preline/dropdown
                        
                      
app/javascript/entrypoints/application.js
                        
                          import HSDropdown from "@preline/dropdown/non-auto";

                          document.addEventListener("turbo:load", () => {
                            HSDropdown.autoInit();
                          });
                        
                      

In that single-package setup, the auto entry is import "@preline/dropdown". Use it only for plain Rails pages with full reloads. As soon as Turbo navigation is involved, the /non-auto entry keeps initialization aligned with the timing you control.

Use Preline UI markup in ERB templates

ERB templates only emit HTML, so Preline UI markup drops straight into a .html.erb file with the same Tailwind CSS classes you would use anywhere else. The Importmap or Vite entry your layout loads is what attaches the behavior once the page reaches the browser.

app/views/shared/_actions_menu.html.erb
                        
                          <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 layout loads the entry that ships Preline UI, for example <%= javascript_importmap_tags %> under Importmap or <%= vite_javascript_tag 'application' %> under Vite Ruby, 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 Turbo to reset on navigation, mark the element with data-turbo-permanent and a stable id so Turbo preserves that subtree across visits.

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 data-turbo-permanent so Turbo keeps it across visits. Create the instance from your entry and guard it with getInstance so a re-run does not double-initialize the same node.

app/javascript/entrypoints/application.js
                        
                          import { HSDropdown } from "preline/non-auto";

                          function initUserMenu() {
                            const el = document.querySelector("#user-menu");
                            if (el && !HSDropdown.getInstance(el)) {
                              new HSDropdown(el);
                            }
                          }

                          document.addEventListener("turbo:load", initUserMenu);
                        
                      

The getInstance guard keeps a single instance per node across Turbo visits. If Turbo is allowed to replace the node, prefer page-level autoInit over a long-lived manual instance, since the element it points at can be swapped out.

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, Rails, and other environments without framework context.

A full page load resets the registry, so plain Rails navigation needs nothing extra. Turbo Drive replaces the <body> without a reload, so every Turbo visit leaves the previous page's registry entries pointing at nodes that are already gone, which is why the turbo:load hook earlier pairs cleanCollection with autoInit on every visit. The same idea applies on a narrower scope any other time you intentionally remove a region of Preline UI markup at runtime without a full Turbo visit: clean just that collection before re-running autoInit so the registry only holds live nodes.

Cleanup
                        
                          HSStaticMethods.cleanCollection(); // every collection
                          HSStaticMethods.cleanCollection("dropdown"); // one collection
                          HSStaticMethods.cleanCollection(["dropdown", "overlay"]); // a few
                        
                      

Call it with no arguments, as the turbo:load hook earlier does, to clean every registry at once. That is the right default for a page-level Turbo re-init hook, since Turbo Drive's full-body swap can leave any plugin's registry holding stale nodes, not just one. Scope it to a name or a list of names only when you are cleaning up a specific widget outside a full Turbo visit.

Optional dependencies only matter for the plugins that use them

Most core plugins do not use jQuery. Dropdowns, overlays, tooltips, popovers, tabs, and similar components use plain JavaScript. Positioning behavior uses @floating-ui/dom.

Each optional dependency has to be loaded, and exposed as a global, before Preline UI's own script evaluates. Under Importmap that means importing it before import "preline" in your entry file: Preline UI checks for each dependency once, at that evaluation point, to decide whether to register the matching plugin at all. If the dependency loads after Preline UI, or is only exposed as a module export without also being assigned to window, the plugin silently fails to register. The fix takes the same shape as a Stimulus controller import, just resolved before the preline import instead of inside it.

jQuery is only relevant for Datatable because datatables.net depends on it. Both window.jQuery and window.DataTable need to be set; if either is missing, Datatable should not initialize. That dependency does not affect dropdowns, overlays, tabs, or tooltips. Note that jQuery's ES module build does not attach itself to window the way its classic UMD build does, so you need import $ from "jquery"; window.jQuery = window.$ = $; explicitly when importing it as a module.

Datatable needs one more thing beyond the script: datatables.net renders its own default search box, page-length dropdown, and pagination row, on top of the ones Preline UI's own markup already provides. Neither variants.css nor datatables.net's JavaScript hides the duplicates for you, so add this rule to your own Tailwind CSS source file:

app/assets/tailwind/application.css
                        
                          /* Hide datatables.net's own search, length, and paging UI in favor of Preline UI's */
                          .dt-layout-row:has(.dt-search),
                          .dt-layout-row:has(.dt-length),
                          .dt-layout-row:has(.dt-paging) {
                            display: none !important;
                          }
                        
                      

Range Slider uses the JavaScript API from noUiSlider (window.noUiSlider). Preline UI remains responsible for the Tailwind CSS markup and behavior wrapper, so you do not need to bring in noUiSlider CSS just to make Preline UI styling work.

Datepicker uses vanilla-calendar-pro (window.VanillaCalendarPro) for the calendar engine in custom-select mode. It also needs window._ (lodash) available, which is easy to miss since lodash is not mentioned by vanilla-calendar-pro's own documentation. Without lodash, Datepicker throws a ReferenceError from inside Preline UI's own bundle during autoInit instead of just skipping initialization.

Datepicker's styling is separate from its JavaScript dependency and from the aggregate variants.css import. The main preline package exports the utility stylesheet directly, so import its public package path in your Tailwind CSS source:

app/assets/tailwind/application.css
                        
                          @import "preline/datepicker-styles-utility.css";
                        
                      

Without it, Datepicker still opens and tracks a selected date correctly, since that behavior lives in the JavaScript, but the calendar renders with no rounded date pills, no selected/today highlight, and no spacing, because none of that comes from variants.css.

File Upload uses Dropzone (window.Dropzone) for drag-and-drop and upload progress, and also depends on lodash (window._) the same way Datepicker does. Both need to be set before Preline UI evaluates for the File Upload plugin to register itself.

The practical checklist

  • Initialize Preline UI from your asset entry such as app/javascript/application.js, not from a .html.erb template.
  • Under Importmap, pin the preline bundle and call window.HSStaticMethods.autoInit(); under Vite Ruby, import from preline/non-auto and run HSStaticMethods.autoInit().
  • Because Rails 7+ ships Hotwire, run cleanCollection then autoInit on turbo:load instead of DOMContentLoaded, and autoInit on turbo:frame-load for lazy Turbo Frames.
  • Scope a single widget to a Stimulus controller's connect() and disconnect() when you want fine-grained lifecycle control.
  • Mark stateful Preline UI widgets with data-turbo-permanent and a stable id when you do not want Turbo to reset them across visits.
  • 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, noUiSlider for Range Slider, vanilla-calendar-pro (+ lodash) for Datepicker, Dropzone (+ lodash) for File Upload. Import each one, assigned to its expected window global, before import "preline".
  • If you use Datatable, add a rule hiding .dt-layout-row:has(.dt-search), .dt-layout-row:has(.dt-length), and .dt-layout-row:has(.dt-paging), otherwise datatables.net's own default search/length/paging UI renders alongside Preline UI's.
  • If you use Datepicker, import preline/datepicker-styles-utility.css into your Tailwind CSS source. Without it, the calendar has no date pill styling or highlight colors.

© 2026 Preline Labs.