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

Blazor

Using Preline UI with Blazor

A practical guide to wiring Preline UI into Blazor across Server and WebAssembly, including the IJSRuntime.InvokeVoidAsync re-init pattern for server renders and the enhancedload navigation hook.

Blazor and Preline UI fit together cleanly once you separate the two layers. Blazor is a .NET framework that builds interactive web UIs in C# and renders HTML through Razor components. Preline UI is the client-side behavior layer that reads that HTML in the browser and wires up the interaction. The Preline UI script your wwwroot folder serves is what carries that behavior across the .NET-to-browser boundary, and JSInterop is what lets your C# components ask it to run again.

This guide walks through the integration choices that matter in a real Blazor project: where to serve Preline UI from wwwroot, how to re-initialize after Blazor Server renders patch the DOM over SignalR, how the enhancedload hook covers .NET 8+ enhanced navigation, how to call autoInit and cleanCollection from C# through IJSRuntime, how to use Preline UI markup inside .razor components, how to avoid stale plugin references as components render, and how data-permanent keeps enhanced navigation's DOM diff from misplacing JavaScript-inserted markup like Datatable's paging buttons.

Start with the Blazor mental model

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

The detail that matters in Blazor is its hosting model, because it decides when the DOM changes. Blazor Server runs components on the server and pushes DOM diffs to the browser over a SignalR WebSocket, patching markup in place without a full reload, much like Phoenix LiveView or Laravel Livewire. Blazor WebAssembly runs components in the browser through WASM and behaves like a traditional single-page app. The .NET 8+ Auto and per-component render modes combine both. In every case Preline UI attaches behavior to the nodes that exist when it initializes, so when Blazor renders new markup you have to run autoInit again. Everything below is about running initialization at the right moment for each render.

Serve Preline UI from wwwroot

Serving Preline UI from wwwroot is the recommended path, and it works the same for Blazor Server and Blazor WebAssembly because both serve static assets from that folder. After npm install preline, copy the prebuilt bundle into wwwroot/js. The full Blazor installation covers the Tailwind CSS build step that brings in the Preline UI variants.

Terminal
                        
                          mkdir -p wwwroot/js
                          cp node_modules/preline/dist/preline.js wwwroot/js/
                        
                      

Reference the compiled Tailwind CSS and the Preline UI bundle from the root component that hosts your app, Components/App.razor on a .NET 8+ Web App or wwwroot/index.html on a standalone Blazor WebAssembly app. The bundled preline.js exposes the window.HSStaticMethods global and runs autoInit for you when the page first loads.

Components/App.razor
                        
                          <link rel="stylesheet" href="css/tailwind.css" />

                          <!-- Before the closing body tag -->
                          <script src="js/preline.js"></script>
                        
                      

If you prefer a managed download over copying files, LibMan can pull Preline UI into wwwroot/lib/preline/ and you reference it from there instead. On a standalone Blazor WebAssembly app the page loads once like an SPA, so this first-load scan covers the initial markup. On Blazor Server, and whenever a component renders new Preline UI markup after the first paint, you also need the re-init step in the next section. Keep the window.HSStaticMethods global in mind: JSInterop reaches it by name from C#.

Reinitialize after Blazor Server renders

This is the key step for Blazor. Blazor Server patches the DOM in place over SignalR after each render, and interactive Blazor WebAssembly components re-render markup in the browser without a reload. Both replace nodes that Preline UI initialized, so the first-load scan is not enough. Because the render happens inside .NET, the cleanest place to re-run autoInit is from C# through IJSRuntime, in the component's OnAfterRenderAsync lifecycle method, which runs after every render.

Components/Pages/Actions.razor
                        
                          @@inject IJSRuntime JS

                          @@code {
                              protected override async Task OnAfterRenderAsync(bool firstRender)
                              {
                                  await JS.InvokeVoidAsync("HSStaticMethods.autoInit");
                              }
                          }
                        
                      

InvokeVoidAsync("HSStaticMethods.autoInit") calls window.HSStaticMethods.autoInit() in the browser. autoInit skips elements that already have plugin instances, so running it after every render is safe. To avoid repeating this in every component, put the lifecycle override in a small base component your interactive pages inherit from.

.NET 8+ static server-side rendering adds enhanced navigation: link clicks and form posts patch the DOM instead of reloading the page, and Blazor dispatches the enhancedload JavaScript event after each patch. Listen for it from the same script that loads Preline UI so new markup gets initialized without any C# code. Call cleanCollection() before autoInit() on every call, not just when you deliberately remove markup — enhanced navigation replaces the previous page's nodes on every patch, so without it the registries accumulate references to elements that are no longer in the document.

Components/App.razor
                        
                      

Use the IJSRuntime override for interactive Server and WebAssembly components, and the enhancedload listener for static SSR with enhanced navigation. This is the same re-init problem you see in Phoenix LiveView and Laravel Livewire, and the fix is the same shape: run autoInit again after the framework changes the DOM.

Choose imports by the level of control you need

The copied preline.js bundle exposes window.HSStaticMethods and the plugin classes such as window.HSDropdown as globals. That global surface is what makes Blazor JSInterop work, because IJSRuntime resolves functions by string name, so HSStaticMethods.autoInit and HSStaticMethods.cleanCollection are reachable straight from C#.

You can scope autoInit and cleanCollection to specific plugins by passing their names, which keeps a render from scanning the whole document when only one region changed.

JavaScript
                        
                          window.HSStaticMethods.autoInit(["dropdown", "overlay"]);
                          window.HSStaticMethods.cleanCollection(["dropdown", "overlay"]);
                        
                      

If you run a JavaScript bundler such as Vite or esbuild alongside your Blazor assets, import from preline/non-auto instead so nothing runs until your code decides the DOM is ready. Attach what JSInterop needs to window so C# can still reach it by name.

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

                          window.HSStaticMethods = HSStaticMethods;
                        
                      

Single plugin packages keep small Blazor surfaces focused

Not every Blazor 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
                        
                      

Copy the single plugin's bundle into wwwroot/js the same way as the full library, then reference its class from C# through JSInterop. The single-package auto entry is import "@preline/dropdown" under a bundler; use it only for static pages, and reach for the /non-auto entry once Blazor renders control the timing.

Components/Pages/Actions.razor
                        
                          @@inject IJSRuntime JS

                          @@code {
                              protected override async Task OnAfterRenderAsync(bool firstRender)
                              {
                                  await JS.InvokeVoidAsync("HSDropdown.autoInit");
                              }
                          }
                        
                      

Use Preline UI markup in Razor components

Razor components emit HTML, so Preline UI markup drops straight into a .razor file with the same Tailwind CSS classes you would use anywhere else. Unlike JSX, Razor uses the standard class attribute rather than className, so the markup is identical to the plain HTML examples. The bundle your root component loads is what attaches the behavior once the page reaches the browser.

Components/ActionsMenu.razor
                        
                          <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 root component loads the script that ships Preline UI 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 a render to reset, give it a stable @@key so Blazor preserves the element across diffs instead of replacing it, then pair it with the manual instance pattern below.

Use manual instances when you own a specific node

Page-level autoInit is good for most markup. A manual instance is better when one specific node needs explicit control. Add a small helper to the script that loads Preline UI, then call it from C# through IJSRuntime, guarding with getInstance so a re-render does not double-initialize the same node.

wwwroot/js/preline-interop.js
                        
                          window.prelineInterop = {
                            initDropdown(el) {
                              if (el && !window.HSDropdown.getInstance(el)) {
                                new window.HSDropdown(el);
                              }
                            }
                          };
                        
                      
Components/UserMenu.razor
                        
                          @@inject IJSRuntime JS

                          <div @@ref="menu" class="hs-dropdown relative inline-flex">
                            ...
                          </div>

                          @@code {
                              private ElementReference menu;

                              protected override async Task OnAfterRenderAsync(bool firstRender)
                              {
                                  if (firstRender)
                                  {
                                      await JS.InvokeVoidAsync("prelineInterop.initDropdown", menu);
                                  }
                              }
                          }
                        
                      

The getInstance guard keeps a single instance per node across renders. If Blazor is allowed to replace the node on a diff, prefer page-level autoInit over a long-lived manual instance, since the element the reference 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, Blazor, and other environments without framework context.

A full page load resets the registry, but Blazor Server patches and interactive re-renders replace nodes in place. When a render removes initialized markup, its instance can linger in the collection as a stale reference. When you intentionally swap out a region of Preline UI markup, clean the relevant collection before re-running autoInit so the registry only holds live nodes. Call it from C# through IJSRuntime, or from the disposal hook of the component that owned the markup.

Cleanup
                        
                          await JS.InvokeVoidAsync("HSStaticMethods.cleanCollection", "dropdown");
                          await JS.InvokeVoidAsync("HSStaticMethods.cleanCollection", new[] { "dropdown", "overlay" });
                        
                      

cleanCollection only touches Preline UI's own registries, such as window.$hsDropdownCollection. A plugin that keeps its own independent registry outside that system — Datatable wraps datatables.net, which tracks tables through jQuery.fn.dataTable.tables() — needs its own handling, and for Datatable specifically the fix isn't cleanup at all. See Preserve JS-inserted markup with data-permanent below.

Preserve JS-inserted markup with data-permanent

Enhanced navigation patches the DOM with a lightweight diff, and the check it uses to decide whether an old element can be reused for a new one compares tag names only — not id, not class, not any other attribute, not children. That is fine for markup Blazor itself renders, since the diff owns both sides of the comparison. It breaks down for markup a plugin builds with JavaScript after the page loads, because Blazor has no record that content exists and can match the old element carrying it against whichever unrelated element happens to sit in the same tree position on the next page.

Datatable hits this directly: its paging buttons are rebuilt by JavaScript on every draw, not rendered by Blazor. Navigate from a page with a Datatable to a page with an accordion, and the old paging <div> — buttons and all — can be reused as the accordion panel's content <div>, because both are plain <div> elements and tag name is all the diff checks. The accordion panel opens showing the Datatable's page-number buttons instead of its own text. Nothing throws, and the only trace is the misplaced markup itself.

Blazor has a purpose-built escape hatch for exactly this. A data-permanent attribute tells the diff that an element's content is managed outside server rendering, so instead of matching it by tag name alone, the diff compares this attribute's value between the old and new element and only treats them as the same node when the value matches too. Give it a unique value per element instance — a bare data-permanent with no value is not enough. getAttribute("data-permanent") returns an empty string for every bare occurrence, so two unrelated data-permanent elements — a Datatable on one page and a Range Slider on another, for instance — both compare equal and can still get merged into each other. A unique value is what actually distinguishes them.

Components/DatatableItem.razor
                        
                      

data-permanent does not replace the enhancedload re-init pattern from earlier in this guide — you still need cleanCollection() and autoInit() to run on every patch. It only stops the diff from misassigning the element; your own re-init code is still what makes Datatable work correctly once the element legitimately needs to change.

Apply it to any element a plugin restructures significantly after the initial render. Datatable's outer wrapper is one case, since datatables.net rewrites a lot of its DOM: wrapping the table, rebuilding the paging buttons on every draw, adding sort classes to header cells. Range Slider is another, since noUiSlider builds the handle, connect, and touch-area elements with JavaScript the same way. Plugins that only toggle classes and aria-* attributes on the exact nodes Blazor already rendered — dropdown, accordion, tabs, overlay — don't insert markup outside Blazor's model and don't need it.

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.

Four plugins gate themselves behind a third-party global and stay off if it is missing: Datatable checks for window.jQuery and window.DataTable (from datatables.net), File Upload checks for window._ and window.Dropzone (from lodash and dropzone), Range Slider checks for window.noUiSlider, and Datepicker checks for window.VanillaCalendarPro (from vanilla-calendar-pro). None of that affects dropdowns, overlays, tabs, or tooltips.

Range Slider uses the JavaScript API from 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.

That check happens exactly once, and the timing is what makes it a Blazor-specific trap. Preline UI builds its internal plugin registry the moment the script is parsed, and for Datatable, File Upload, Range Slider, and Datepicker it captures the constructor to use for the entire lifetime of the page, not the outcome of a live check. In a traditional multi-page app this is harmless, since a full navigation re-parses the script on every page and the checks run fresh. Blazor Server and WebAssembly load js/preline.js once, in the root component, and reuse that same parsed script across every in-app navigation over the SignalR circuit or client-side router. If window.jQuery, window._, window.noUiSlider, or window.VanillaCalendarPro is not already defined at that one moment, the corresponding plugin is permanently disabled for the rest of the session: loading the library afterward, on the page that actually needs it, and calling autoInit again will not construct it, because the constructor Preline UI captured for that plugin type is already null.

Datepicker is easy to miss here because its calendar engine, vanilla-calendar-pro, isn't an obviously optional add-on the way jQuery or Dropzone are — it's easy to assume Preline UI bundles it. It doesn't: load vanilla-calendar-pro's own build as a plain global script, the same way as the other three.

Load these dependencies unconditionally in the same root component that loads Preline UI, before the preline.js <script> tag, even on pages that do not use Datatable, File Upload, Range Slider, or Datepicker. Do not defer them to the component that needs them, and do not inject them dynamically through IJSRuntime once the app has already started, since by then Preline UI's script has already parsed and locked in its decision.

Components/App.razor
                        
                      

This is a real tradeoff: every page pays for the extra script weight even when it never renders a Datatable, File Upload, Range Slider, or Datepicker. That cost buys the only setup where those four plugins actually construct under Blazor's single-script-lifetime model.

The practical checklist

  • Serve Preline UI from wwwroot and load js/preline.js from your root component; the bundle exposes window.HSStaticMethods and runs autoInit on first load.
  • For interactive Server and WebAssembly components, re-run autoInit from C# in OnAfterRenderAsync with JS.InvokeVoidAsync("HSStaticMethods.autoInit"), since the DOM is patched without a reload.
  • For .NET 8+ static SSR with enhanced navigation, listen for the enhancedload event and call cleanCollection() then autoInit() from JavaScript on every patch, not just the first load.
  • Use the standard class attribute in .razor markup, and give stateful widgets a stable @@key so Blazor preserves them across diffs.
  • Guard manual instances with getInstance, and call cleanCollection through IJSRuntime when you intentionally remove a region of initialized markup.
  • On Datatable and Range Slider wrappers, add data-permanent="@_permanentId" with a unique value per instance, generated in @@code. A bare data-permanent is not enough: two unrelated bare data-permanent elements both have an empty attribute value, compare as a match, and can get merged into each other across pages.
  • Load jquery, datatables.net, lodash, dropzone, noUiSlider, and vanilla-calendar-pro globally in your root component, before the preline.js <script> tag, on every page — not lazily per page. Datatable, File Upload, Range Slider, and Datepicker capture their constructor once when Preline UI's script parses, so loading these afterward never recovers them.

© 2026 Preline Labs.