Using Preline UI with React + Vite
A practical guide to using Preline UI JavaScript plugins in React + Vite projects, including autoInit, module imports, cleanup, and optional dependencies.
View guideUpdate v4.2 - New components, 10+ framework guides, and quality improvements. Visit Changelog
A practical guide to wiring Preline UI into Remix without fighting server rendering, route transitions, and React lifecycle timing.
Remix has the same core integration rule as any server-rendered React app: let Remix render and hydrate the markup first, then let Preline UI attach behavior in the browser. The clean path is a small client effect that runs after route content exists in the DOM.
The current Preline UI package exposes module entries that fit that model: 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. Remix renders React routes, can stream or server-render HTML, and then hydrates on the client. Preline UI should not be part of the server render path; it should read the browser DOM after React has committed it.
That keeps responsibilities clear. Remix owns routing, data loading, and rendering. Preline UI owns the JavaScript behavior around already-rendered dropdowns, overlays, tabs, selects, tooltips, and other marked-up components.
Put page-level initialization in a component that renders in the Remix root layout and uses useEffect. The dynamic import keeps Preline UI out of server execution and gives Remix time to hydrate route markup before autoInit scans it.
import { useLocation } from "@remix-run/react";
import { useEffect } from "react";
export default function PrelineClient() {
const location = useLocation();
useEffect(() => {
let cancelled = false;
import("preline/non-auto").then(({ HSStaticMethods }) => {
if (!cancelled) HSStaticMethods.autoInit();
});
return () => {
cancelled = true;
};
}, [location.pathname]);
return null;
}
Render that component near the end of the document body, after <Outlet />, so route content has already been committed when the effect runs.
import { Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import PrelineClient from "./components/PrelineClient";
export default function App() {
return (
<html lang="en">
<body>
<Outlet />
<PrelineClient />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
Remix can replace route content during client-side navigation without reloading the page. Use useLocation() as the signal to scan the new DOM after the route changes.
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 route-level scans are expected.
If a route only needs a few plugins, pass collection keys such as dropdown, overlay, select, tooltip, tabs, or range-slider.
HSStaticMethods.autoInit(["dropdown", "overlay"]);
For a full Preline UI installation, preline/non-auto is the practical Remix 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 from the same entry are useful inside code that only runs on the client. In shared Remix modules, prefer the dynamic import inside useEffect so server execution never touches browser-only plugin logic.
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 is useful when a Remix route only needs one or two interactive primitives.
npm install @preline/dropdown
import { useEffect } from "react";
import HSDropdown from "@preline/dropdown/non-auto";
export default function DropdownClient() {
useEffect(() => {
HSDropdown.autoInit();
}, []);
return (
<div className="hs-dropdown relative inline-flex">
...
</div>
);
}
In that single-package setup, the auto entry is import "@preline/dropdown". It is useful for simple static pages. In Remix route components, /non-auto keeps initialization aligned with hydration and route timing.
autoInit is good for page-level scans. A manual instance is better when one Remix component owns one plugin root and can destroy it directly.
import { useEffect, useRef } from "react";
import {
HSDropdown,
type IHTMLElementFloatingUI,
} from "preline/non-auto";
export default function Dropdown() {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!dropdownRef.current) return;
const dropdown = new HSDropdown(
dropdownRef.current as unknown as IHTMLElementFloatingUI,
);
return () => {
dropdown.destroy();
};
}, []);
return (
<div ref={dropdownRef} className="hs-dropdown relative inline-flex">
<button className="hs-dropdown-toggle" type="button">
Toggle
</button>
<div className="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 a Remix or React context and is part of why the same codebase works in plain HTML, React, Vue, Angular, Svelte, SolidJS, Next.js, Nuxt, and Remix.
In Remix, call destroy() 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.
jQuery is only relevant for Datatable because datatables.net depends on it. Range Slider uses the JavaScript API from noUiSlider. These dependencies do not need to be loaded for dropdowns, overlays, tabs, or tooltips.
If you initialize optional plugins through HSStaticMethods, make the 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 general Remix Preline client loader.
import { useEffect } from "react";
import noUiSlider from "nouislider";
export default function RangeSliderClient() {
useEffect(() => {
(
globalThis as typeof globalThis & {
noUiSlider: typeof noUiSlider;
}
).noUiSlider = noUiSlider;
import("preline/non-auto").then(({ HSRangeSlider }) => {
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.
preline/non-auto when you need explicit lifecycle control.autoInit after Remix navigation with useLocation().@preline/dropdown/non-auto when a route only needs one plugin.destroy() for reusable components that own one plugin root.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 React + Vite projects, including autoInit, module imports, cleanup, and optional dependencies.
View guideA practical guide to using Preline UI JavaScript plugins in Next.js projects, including client components, App Router navigation, module imports, cleanup, and optional dependencies.
View guide