Using Preline UI with Hugo
A practical guide to using Preline UI JavaScript plugins in Hugo projects, including static scripts, module imports, Hugo Pipes, cleanup, and optional dependencies.
View guideUpdate v5.0 - Preline MCP, AI Prompts, Animated Icons and more. Visit Changelog
A practical guide to wiring Preline UI into server-rendered Express pages without exposing your whole node_modules directory.
Express usually serves HTML templates and static assets. That is a natural fit for Preline UI: your view engine renders the Tailwind CSS markup, and Preline UI attaches behavior in the browser after the page loads.
The current Preline UI package supports two practical Express paths: serve the browser script in dist/preline.js for ordinary server-rendered pages, or bundle preline/non-auto when you want explicit initialization timing and a smaller public asset surface.
Preline UI is a DOM-driven Tailwind CSS component system. Express renders HTML with EJS, Pug, Handlebars, Nunjucks, or plain responses. Preline UI reads the final browser DOM and wires up components such as dropdowns, overlays, tabs, selects, and tooltips.
There is no framework lifecycle to synchronize with on a normal Express page. Load the script after the markup, or initialize explicitly from a small browser module.
Avoid exposing the entire node_modules directory from Express. Copy only the specific browser files you actually reference, not the whole dist directory, which also contains per-plugin bundles, sourcemaps, and type declarations you will not serve.
npm install preline
mkdir -p public/javascripts
cp node_modules/preline/dist/preline.js public/javascripts/preline.js
cp node_modules/preline/dist/non-auto.mjs public/javascripts/non-auto.mjs
const express = require("express");
const path = require("path");
const app = express();
app.use(express.static(path.join(__dirname, "public")));
For standard Express pages, load dist/preline.js near the end of your shared layout. This browser build initializes after the page load event.
<body>
<%- body %>
<script src="/javascripts/preline.js"></script>
</body>
Use this when each navigation returns a complete HTML document. Express sends the markup, the browser loads Preline UI, and the plugin collections start fresh on the new page.
Point an explicit @source at your own view templates too. Tailwind CSS v4's automatic content detection already picks up most template files, but declaring the glob yourself keeps the build reproducible if that detection ever misses a file, for example when the templates directory is .gitignored or the CLI runs from an unexpected working directory.
Import preline/theme.css directly from the package to load Preline's default theme. Tailwind CSS resolves this bare specifier from node_modules at build time, the same way it resolves the @source path above, so there is nothing extra to wire up in Express.
@import "tailwindcss";
/* Your view templates (adjust the glob to match your view engine) */
@source "../views/**/*.ejs";
@source "../node_modules/preline/dist/*.js";
@import "preline/variants.css";
@import "preline/theme.css";
To use a different look, either swap in one of Preline's built-in preset themes (see the Theme docs), or generate a custom brand theme with the bundled generator. The generator resolves one of a small allowlist of conventional theme directories in your own project and writes a single <theme-name>.css file there. It is not a file the package ships for you to edit in place.
node node_modules/preline/skills/theme-generator/scripts/run-theme-generator.js \
--name brand \
--primary-color "#2F6BFF" \
--output styles/themes/brand.css
Import the generated file with a relative path instead of the default theme import, and drop the preline/theme.css line:
@import "tailwindcss";
@source "../node_modules/preline/dist/*.js";
@import "preline/variants.css";
@import "./themes/brand.css";
The generated file lives next to styles/tailwind.css, not under public/. It is a source file that Tailwind CSS compiles in, not an asset Express serves directly, so no extra Express wiring is needed.
If your Express project already bundles browser assets with esbuild, Vite, Rollup, or Webpack, import preline/non-auto and decide when the scan runs.
import { HSStaticMethods } from "preline/non-auto";
const initPreline = () => {
HSStaticMethods.autoInit();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPreline, { once: true });
} else {
initPreline();
}
document.addEventListener("preline:init", initPreline);
{
"scripts": {
"build:js": "esbuild assets/js/preline.ts --bundle --format=esm --platform=browser --outfile=public/js/preline.js"
}
}
For a whole Express site bundle, preline/non-auto gives you HSStaticMethods and named plugin classes without automatic page-load behavior. For a copied static module, the equivalent browser entry is /javascripts/non-auto.mjs.
HSStaticMethods.autoInit(["dropdown", "overlay"]);
HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
There is a single preline package, not per-plugin packages. Each plugin is still reachable on its own through the package's ./plugins/* subpath export, for example preline/plugins/dropdown, preline/plugins/overlay, preline/plugins/select, or preline/plugins/range-slider. This is useful when one Express route group only needs one or two interactive primitives and pulling in the full bundle would be wasteful.
import HSDropdown from "preline/plugins/dropdown-non-auto";
document.addEventListener("DOMContentLoaded", () => {
HSDropdown.autoInit();
});
autoInit is enough for normal Express templates. Manual instances are useful when a custom browser script owns one plugin root and can destroy it before replacing that DOM.
import {
HSDropdown,
type IHTMLElementFloatingUI,
} from "preline/non-auto";
const root = document.querySelector<HTMLDivElement>(".hs-dropdown");
if (root) {
const dropdown = new HSDropdown(
root as unknown as IHTMLElementFloatingUI,
);
window.addEventListener("beforeunload", () => {
dropdown.destroy();
});
}
A normal Express navigation reloads the document, so plugin collections start cleanly. Extra cleanup only matters if you use HTMX, Turbo, PJAX, websockets, or another browser script that injects and removes partial HTML without a page reload.
document.dispatchEvent(new Event("preline:init"));
HSStaticMethods.cleanCollection("dropdown");
HSStaticMethods.autoInit("dropdown");
Most core plugins do not use jQuery. Dropdowns, overlays, tooltips, popovers, tabs, and similar components use plain JavaScript. Positioning behavior uses @floating-ui/dom.
Four plugins gate themselves behind third-party globals, and each checks for its dependency once, when the plugin module itself parses, rather than lazily when a matching element first appears in the DOM. If the dependency is missing at that moment, the plugin silently no-ops for the rest of the page; reloading the library afterward and calling autoInit() again does not recover it.
jQuery and datatables.net’s own DataTable global: two separate globals, not one. datatables.net registers itself as a jQuery plugin, but the Preline plugin also references the bare DataTable constructor directly.lodash (the bare _ global) and vanilla-calendar-pro.lodash and Dropzone; the lodash dependency is easy to miss since it is not implied by the plugin name.noUiSlider.These dependencies do not need to be loaded for dropdowns, overlays, tabs, tooltips, or any other plugin that does not appear in the list above.
If you initialize optional plugins through HSStaticMethods, make every optional library available before the first import of preline/non-auto. The static methods build their plugin map when that module is loaded, and that module can be cached by your Express browser bundle.
import noUiSlider from "nouislider";
(
globalThis as typeof globalThis & {
noUiSlider: typeof noUiSlider;
}
).noUiSlider = noUiSlider;
const { HSRangeSlider } = await import("preline/non-auto");
HSRangeSlider.autoInit();
Datatable needs the same treatment, but with both of its globals set first:
import jQuery from "jquery";
import DataTable from "datatables.net";
const g = globalThis as typeof globalThis & {
jQuery: typeof jQuery;
DataTable: typeof DataTable;
};
g.jQuery = jQuery;
g.DataTable = DataTable;
const { HSDataTable } = await import("preline/non-auto");
HSDataTable.autoInit();
dist/preline.js from a public Preline copy for simple server-rendered Express pages.node_modules directory as a public static route.preline/non-auto when you need explicit initialization timing.autoInit directly after dynamic fragment swaps.preline/plugins/dropdown-non-auto instead of the full bundle.preline/non-auto import when using HSStaticMethods, or initialize late optional plugins with their direct classes.A practical guide to using Preline UI JavaScript plugins in Hugo projects, including static scripts, module imports, Hugo Pipes, cleanup, and optional dependencies.
View guideA practical guide to using Preline UI JavaScript plugins in HTML + Vite projects, including direct plugin imports, non-auto timing, manual instances, cleanup, and optional dependencies.
View guide