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

Flask

Using Preline UI with Flask

A practical guide to wiring Preline UI into Flask across static files and a Vite build step, including the htmx re-init hook for projects that use htmx.

Flask and Preline UI fit together cleanly once you separate the two layers. Flask is a Python micro web framework that renders HTML on the server with the Jinja2 templating engine and has no built-in frontend asset pipeline, so its CSS and JavaScript are served from the static/ folder or built by a separate tool such as Vite. Preline UI is the client-side behavior layer that reads that HTML in the browser and wires up the interaction. The small script your static/ folder ships is what carries Preline UI across that server-to-client boundary.

This guide walks through the integration choices that matter in a real Flask project: where to run autoInit when you serve Preline UI from static files, how the same idea maps to a Vite build step, how to keep Preline UI working when htmx swaps the DOM without a reload, how to use Preline UI markup inside .html Jinja2 templates, and how to avoid stale plugin references as pages change.

Start with the Flask mental model

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

The detail that matters in Flask is where the JavaScript runs. Jinja2 templates render server-side HTML, so Preline UI cannot initialize from inside a .html template. It needs a client script that your static/ folder serves, either the prebuilt Preline UI bundle you copy into static/js/ or a Vite-built entrypoint. Flask is a traditional multi-page app, so each navigation is a full HTTP request and the page reloads, which means a single page-load scan is enough. As soon as you add htmx for partial DOM updates, navigation becomes a partial swap and you have to re-run initialization. Everything below is about putting autoInit in the right place for each case.

Serve Preline UI from static files

Serving Preline UI from static/ is the simplest path, and it is the default for most Flask projects because they do not run a JavaScript bundler. After npm install preline, copy the prebuilt bundle into your static directory. The full Flask installation covers the CSS build step that brings in the Preline UI Tailwind CSS variants.

Terminal
                        
                          cp node_modules/preline/dist/preline.js static/js/
                        
                      

Reference the compiled Tailwind CSS and the Preline UI bundle from your base Jinja2 layout with url_for('static', ...). The bundled preline.js exposes the window.HSStaticMethods global and runs autoInit for you when the page loads, so a plain Flask page needs no extra script.

templates/base.html
                        
                          <link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">

                          <!-- Before the closing body tag -->
                          <script src="{{ url_for('static', filename='js/preline.js') }}"></script>
                        
                      

Because standard Flask navigation is a full page load, the bundle re-runs and scans the fresh DOM on every page. autoInit also skips nodes that already have plugin instances, so re-running it is always safe. Keep the window.HSStaticMethods global in mind: the htmx section below uses it to re-initialize after a partial swap.

Use Vite when you want a build step

If you prefer a bundler over copying files, run Vite alongside Flask and point its build output at your static/ folder. With Vite you install Preline UI from npm and import it from a JavaScript entrypoint. 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
                        
                      

Preline UI does not ship a compiled CSS bundle to import from JavaScript. Styling still comes from Tailwind CSS processing preline/variants.css and your theme, exactly as in the Flask installation guide, regardless of whether Vite or the static bundle loads the JavaScript.

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

                          function autoInit() {
                            HSStaticMethods.autoInit();
                          }

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

Reference the built bundle from your base layout with url_for('static', ...), the same way you would any other static asset. The rest of this guide applies the same way to both paths: the static bundle exposes window.HSStaticMethods, while Vite exposes the plugin classes through bundled module imports from preline/non-auto.

Reinitialize on htmx swaps

This is the key step for any Flask project that uses htmx. htmx intercepts requests from hx-* attributes and replaces part of the page with the HTML fragment your Flask route returns, instead of doing a full reload. Your initial page-load scan runs once, but new markup that htmx swaps in never gets initialized. This is the same re-init problem you see with Turbo in Symfony and Rails, and the fix is the same shape: re-run autoInit after each swap.

htmx fires htmx:afterSwap after it places the new content in the DOM. Listen for it and re-run autoInit. autoInit skips elements that already have plugin instances, so re-running it after every swap keeps the whole page covered without double-initializing.

templates/base.html
                        
                          <script src="{{ url_for('static', filename='js/preline.js') }}"></script>

                          <script>
                            // Fires after htmx swaps a fragment into the DOM
                            document.body.addEventListener("htmx:afterSwap", () => {
                              window.HSStaticMethods.autoInit();
                            });
                          </script>
                        
                      

Under the Vite path, do the same from your entrypoint with the imported HSStaticMethods instead of the global. If a swap only replaces markup without removing initialized nodes you do not want reset, that is all you need. When a swap removes a region of Preline UI markup, pair the re-init with the cleanup covered further down so the registry only holds live nodes.

Choose imports by the level of control you need

Under the Vite path, 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 htmx controls when the DOM changes.

assets/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/dropdown.js
                        
                          import { HSDropdown } from "preline/non-auto";

                          HSDropdown.autoInit();
                        
                      

With the static bundle there is no bundler to tree-shake, so the copied preline.js exposes the same plugin classes and HSStaticMethods through window.HSStaticMethods instead.

Single plugin packages keep small Flask surfaces focused

Not every Flask 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, and it pairs naturally with the Vite path.

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

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

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

Use Preline UI markup in Jinja2 templates

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

templates/partials/actions_menu.html
                        
                          <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 base layout loads the script that ships Preline UI, the copied preline.js or your Vite-built bundle, so every page that renders Preline UI markup also ships the script that initializes it. When you return the same markup from an htmx fragment route, the htmx:afterSwap re-init from above is what wires up the freshly swapped subtree.

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

assets/main.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("DOMContentLoaded", initUserMenu);
                        
                      

The getInstance guard keeps a single instance per node. If htmx is allowed to swap the node out, prefer page-level autoInit on htmx:afterSwap over a long-lived manual instance, since the element it points at can be replaced. With the static bundle, reach the same plugin class through window.HSStaticMethods instead of the module import.

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

A full page load resets the registry, so plain Flask navigation needs nothing extra. An htmx swap replaces part of the DOM without a reload, so a registry entry can linger as a stale reference when its markup is swapped out. When a swap removes 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 jQuery or any other third-party library. Dropdowns, overlays, tooltips, popovers, tabs, and similar components use plain JavaScript. Positioning behavior uses @floating-ui/dom.

A handful of plugins do lean on a third-party library that must already exist as a global on window: Datatable needs jquery and datatables.net, Range Slider needs nouislider, Datepicker needs lodash and vanilla-calendar-pro, and File Upload needs lodash and dropzone.

Install whichever of these a page actually needs, then copy the built file into static/js/ next to preline.js:

Terminal
                        
                          npm install jquery datatables.net lodash vanilla-calendar-pro dropzone nouislider

                          cp node_modules/jquery/dist/jquery.min.js static/js/
                          cp node_modules/datatables.net/js/dataTables.min.js static/js/
                          cp node_modules/lodash/lodash.min.js static/js/
                          cp node_modules/vanilla-calendar-pro/index.js static/js/vanilla-calendar-pro.js
                          cp node_modules/dropzone/dist/dropzone-min.js static/js/
                          cp node_modules/nouislider/dist/nouislider.min.js static/js/
                        
                      

The detail that actually breaks Flask projects is load order, not just presence. Each of those plugins checks for its dependency on window once, at the moment the Preline UI bundle itself is parsed, not lazily and not re-checked later. Load every dependency's script tag before preline.js (or before the Vite entry that imports Preline UI) on any page that renders that plugin's markup. If the dependency loads after, or is fetched asynchronously and only resolves after Preline UI's script has already run, that plugin is disabled for the rest of the page: loading the dependency afterward and calling autoInit() again does not recover it, since the check already happened.

templates/base.html
                        
                          <!-- Load every optional dependency the page needs before Preline UI -->
                          <script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
                          <script src="{{ url_for('static', filename='js/dataTables.min.js') }}"></script>
                          <script src="{{ url_for('static', filename='js/lodash.min.js') }}"></script>
                          <script src="{{ url_for('static', filename='js/vanilla-calendar-pro.js') }}"></script>
                          <script src="{{ url_for('static', filename='js/dropzone-min.js') }}"></script>
                          <script src="{{ url_for('static', filename='js/nouislider.min.js') }}"></script>

                          <script src="{{ url_for('static', filename='js/preline.js') }}"></script>
                        
                      

Datatable needs one more thing beyond the scripts: 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 CSS:

static/css/input.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 is styled entirely by Preline UI's own Tailwind CSS classes, passed straight into the plugin's cssClasses option, so noUiSlider's own stylesheet is never needed.

Datepicker's calendar styles are separate from the aggregate variants.css import. The main preline package exports them directly, so import the utility stylesheet from its public package path:

static/css/input.css
                        
                          @import "preline/datepicker-styles-utility.css";
                        
                      

Without it, the Datepicker plugin 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.

The practical checklist

  • Initialize Preline UI from a script your static/ folder serves, not from a .html Jinja2 template.
  • Serving the copied preline.js bundle is the simplest path and exposes window.HSStaticMethods; with Vite, import from preline/non-auto and run HSStaticMethods.autoInit() once the DOM is ready.
  • Reference assets in your base layout with url_for('static', ...) so every page that renders Preline UI markup also ships the script that initializes it.
  • If the project uses htmx, run autoInit on htmx:afterSwap so swapped-in markup gets initialized.
  • Guard manual instances with getInstance, and call cleanCollection when a swap removes 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, lodash + vanilla-calendar-pro for Datepicker, and lodash + dropzone for File Upload. Load each one's script before preline.js on every page that uses it, since the plugin checks for it on window once, at parse time.
  • If you use Datepicker, import preline/datepicker-styles-utility.css into your Tailwind CSS entry. Without it, the calendar has no date pill styling or highlight colors.
  • 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.

© 2026 Preline Labs.