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 Qwik without fighting resumability, Qwik City navigation, and browser-only DOM timing.
Qwik changes the usual client-side mental model because it resumes server-rendered HTML instead of eagerly hydrating the whole page. Preline UI still fits that model, but it should be initialized only when the relevant DOM is visible in the browser.
The current Preline UI package exposes module entries that work well with Qwik's browser boundary: preline/non-auto for explicit scans, named plugin classes for manual instances, and single-plugin packages for smaller route surfaces.
Preline UI is a DOM-driven Tailwind CSS component system. Qwik renders HTML, serializes state, and resumes only the pieces of JavaScript that become necessary. Preline UI should therefore live in the browser-only part of a Qwik component, after the element it needs to scan already exists.
That keeps the split clean. Qwik owns rendering and resumability. Preline UI owns the JavaScript behavior around already-rendered dropdowns, overlays, tabs, selects, tooltips, and other marked-up components.
Qwik's useVisibleTask$ is the practical place to initialize a browser DOM library. Use a dynamic import so preline/non-auto is loaded when the browser-side task runs, not as part of server rendering.
import { component$, useVisibleTask$ } from "@builder.io/qwik";
export default component$(() => {
useVisibleTask$(async () => {
const { HSStaticMethods } = await import("preline/non-auto");
HSStaticMethods.autoInit();
});
return null;
});
Use this pattern deliberately. A visible task wakes JavaScript for that component, which is exactly what you want for third-party DOM behavior, but it should stay close to the route or component that actually contains Preline UI markup.
Qwik City can replace route content without a full page reload, so the visible task needs to rerun after every client-side navigation. Tracking useLocation().url.pathname with track() looks like the obvious trigger, but that signal can update in Qwik's reactivity graph before Qwik City finishes patching the <Slot /> with the new route's markup, so autoInit() can then scan a DOM that does not have the new route's elements yet, and nothing reruns it afterward since the tracked signal only changes once per navigation. This is not a rare edge case; it reproduces reliably enough to break every plugin on a route reached this way.
A MutationObserver watching the element the <Slot /> renders into reacts to the actual DOM change Qwik City performs instead, which is reliable regardless of exactly when it lands relative to any signal update:
import { component$, Slot, useSignal, useVisibleTask$ } from "@builder.io/qwik";
export default component$(() => {
const contentRef = useSignal<HTMLDivElement>();
useVisibleTask$(
({ cleanup }) => {
const target = contentRef.value;
if (!target) return;
let debounce: ReturnType<typeof setTimeout> | undefined;
const observer = new MutationObserver(() => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const { HSStaticMethods } = await import("preline/non-auto");
HSStaticMethods.autoInit();
}, 0);
});
observer.observe(target, { childList: true, subtree: true });
cleanup(() => {
clearTimeout(debounce);
observer.disconnect();
});
},
{ strategy: "document-ready" },
);
return (
<div ref={contentRef}>
<Slot />
</div>
);
});
autoInit is collection-aware: it filters stale nodes that are no longer in the document and skips elements that already have plugin instances, so repeated calls from the observer are cheap and expected, not just tolerated. Debouncing with a bare setTimeout(fn, 0) is enough to collapse a burst of mutations from a single route swap into one call.
Use { strategy: "document-ready" } here too, rather than the default intersection-observer strategy. The default gates the task's first run behind the tracked element's reported viewport-intersection state, which has been observed to never fire at all in some real conditions (a hidden or backgrounded tab reporting document.visibilityState !== "visible" indefinitely), leaving Preline uninitialized no matter how long the page sits there. document-ready runs as soon as the DOM is interactive, independent of visibility.
For a full Preline UI installation, preline/non-auto is the practical Qwik default. It gives you HSStaticMethods and named plugin classes without relying on automatic page-load initialization.
const { HSStaticMethods } = await import("preline/non-auto");
HSStaticMethods.autoInit(["dropdown", "overlay"]);
HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
Static named imports can be useful in code that you know is browser-only, but dynamic imports inside useVisibleTask$ are easier to reason about in Qwik because the server path never evaluates Preline UI plugin code.
Preline UI plugins are also published as individual single-plugin packages, such as @preline/dropdown, @preline/overlay, @preline/select, or @preline/range-slider, one per plugin, as a smaller install than preline, which bundles every plugin's code to build the non-auto aggregator regardless of which one you actually use. This is useful when a Qwik route only needs one or two interactive primitives.
npm install @preline/dropdown
Each single-plugin package's default entry, @preline/dropdown, behaves like the main preline package and calls autoInit() itself on window's load event, which in a Qwik app is close to useless on its own: load fires once, and a route mounted later, the normal case for anything reached through client-side navigation, has already missed it. Import @preline/dropdown/non-auto instead, which has no such side effect, and drive initialization from useVisibleTask$ the same as everywhere else in this guide.
In this package version, /non-auto ships with no .d.ts declaration file of its own; only the package's default entry is typed, from its index.d.ts. Declare the subpath once as an ambient module that re-exports the default entry's type, and every import of it project-wide is fully typed with no per-call-site cast:
declare module "@preline/dropdown/non-auto" {
export { default } from "@preline/dropdown";
}
import { component$, useVisibleTask$ } from "@builder.io/qwik";
export default component$(() => {
useVisibleTask$(async () => {
const { default: HSDropdown } = await import("@preline/dropdown/non-auto");
HSDropdown.autoInit();
});
return (
<div class="hs-dropdown relative inline-flex">
...
</div>
);
});
This is genuinely typed, not just silenced: TypeScript will still catch a call to a method that does not exist on HSDropdown. If you would rather skip the ambient declaration for a one-off import, @preline/dropdown (no /non-auto) is fully typed out of the box. Keep the explicit HSDropdown.autoInit() call regardless, since its own load-triggered init will not do anything useful for a Qwik route that did not exist yet when load fired; it also attaches a window resize listener and sets window.HSDropdown unconditionally, which are harmless but worth knowing are there.
autoInit is good for route-level scans. A manual instance is better when one Qwik component owns one plugin root and can destroy it through the visible task cleanup.
import {
component$,
useSignal,
useVisibleTask$,
} from "@builder.io/qwik";
import type { IHTMLElementFloatingUI } from "preline/non-auto";
export default component$(() => {
const dropdownRef = useSignal<HTMLDivElement>();
useVisibleTask$(async ({ cleanup }) => {
const root = dropdownRef.value;
if (!root) return;
const { HSDropdown } = await import("preline/non-auto");
const dropdown = new HSDropdown(
root as unknown as IHTMLElementFloatingUI,
);
cleanup(() => dropdown.destroy());
});
return (
<div ref={dropdownRef} class="hs-dropdown relative inline-flex">
<button class="hs-dropdown-toggle" type="button">
Toggle
</button>
<div class="hs-dropdown-menu hidden">Menu</div>
</div>
);
});
The cast is only for strict TypeScript. At runtime the plugin receives the actual dropdown root element and augments it with Floating UI internals.
Preline UI stores plugin instances in internal collections such as window.$hsDropdownCollection. That registry lets plugins coordinate without Qwik context and is part of why the same codebase works in plain HTML, React, Vue, Angular, Svelte, SolidJS, Next.js, Nuxt, Remix, and Qwik.
In Qwik, call destroy() from the visible task cleanup for manual instances. For route-level scans, autoInit already filters removed nodes for the plugin collections it scans.
HSStaticMethods.cleanCollection("dropdown");
HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
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 need an optional dependency on window before they will initialize at all:
jquery and datatables.net-dt (or another datatables.net-* styling package).vanilla-calendar-pro and lodash. The lodash dependency is easy to miss since it is not obvious from the plugin's name, but the plugin calls _.merge/_.mergeWith internally to merge options.nouislider.dropzone and lodash (shared with Datepicker).None of these need to be loaded for dropdowns, overlays, tabs, tooltips, or any other plugin that does not appear in that list.
If you initialize optional plugins through HSStaticMethods.autoInit(), make the optional library available on window before the first import of preline/non-auto, not just before autoInit() runs. The static methods build their plugin map once, when that module is first evaluated, checking typeof window.noUiSlider !== "undefined" and similar; that check does not rerun later, so a plugin whose dependency was not yet on window at that exact moment stays disabled for the rest of the session even if you set the global afterward. File Upload's dependency is stricter still: Preline runs Dropzone.autoDiscover = false as a side effect of merely importing the plugin's module, so window.Dropzone has to exist before that import too, or Dropzone's own auto-discovery can double-initialize elements.
import { component$, useVisibleTask$ } from "@builder.io/qwik";
export default component$(() => {
useVisibleTask$(async () => {
const { default: noUiSlider } = await import("nouislider");
(
globalThis as typeof globalThis & {
noUiSlider: typeof noUiSlider;
}
).noUiSlider = noUiSlider;
const { HSRangeSlider } = await import("preline/non-auto");
HSRangeSlider.autoInit();
});
return null;
});
For a late-loaded optional plugin, use the direct plugin class like HSRangeSlider. If you want to use HSStaticMethods.autoInit(["range-slider"]) instead, expose noUiSlider in the same first loader before any other preline/non-auto import happens. In a shared Preline init helper (see the installation guide) that means setting every optional global your app needs, for every plugin you use, before that helper's own first preline/non-auto import, not spread across each plugin's own component.
useVisibleTask$, using { strategy: "document-ready" } rather than the default, which can fail to fire at all depending on the tracked element's reported viewport-visibility state.preline/non-auto when you need explicit lifecycle control.<Slot /> container with a MutationObserver, not by tracking useLocation().url.pathname alone, since that signal is not guaranteed to update in sync with when the new route's DOM actually lands.preline, use single-plugin packages such as @preline/dropdown when a route only needs one plugin. Install with npm install @preline/dropdown, then import @preline/dropdown/non-auto to skip the package's own window load-triggered autoInit() and drive it from useVisibleTask$ instead. The /non-auto entry currently ships without its own type declarations; add a one-line ambient declare module in global.d.ts that re-exports the package's default-entry type, and every import of it is fully typed project-wide.vanilla-calendar-pro and lodash for Datepicker, nouislider for Range Slider, Dropzone and lodash for File Upload) on window before the first 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 SolidJS projects, including onMount, createEffect, router rescans, module imports, cleanup, and optional dependencies.
View guide