Better JavaScript interaction
for Blazor
Load scripts per page instead of per app, call JavaScript without writing wrapper functions, hook browser events onto your components and wrap JS libraries in a base component.
- net10.0
- net9.0
- net8.0
- net7.0
- net6.0
- netstandard2.1
BlazorJS is a small package with no dependencies beyond the Blazor framework itself. It works in Blazor Server, Blazor WebAssembly and Blazor Web Apps.
dotnet run --project BlazorJSSample.
Scripts & Styles
Load a file when a page needs it, unload it again on dispose.
Dynamic invocation
Write JavaScript as a C# lambda — no .js wrapper needed.
Event interop
Any DOM event, including click-outside, straight into your component.
Save a file
A real save dialog through the File System Access API, streamed.
Resize & visibility
ResizeObserver and IntersectionObserver as C# callbacks.
Clipboard
Copy and read with a fallback for insecure contexts.
JS base component
Import a module, create the JS object, keep parameters in sync.
Installation
dotnet add package BlazorJS
Add the namespace to your _Imports.razor:
@using BlazorJS
@using BlazorJS.Attributes
@using BlazorJS.JsInterop
That is all. The browser side is registered automatically through a Blazor JS initializer, so there is no service registration and no script tag to add.
Scripts & Styles
The <Scripts> component loads JavaScript files for the page or component it sits on.
A file that is already in the document is skipped, and on dispose the file is removed again —
so a page only pays for what it actually uses.
<Scripts src="js/myjsfile.js"></Scripts>
<!-- multiple files, comma separated -->
<Scripts src="js/myjsfile.js, js/myjsfile2.js"></Scripts>
<!-- stylesheets work too, the extension decides -->
<Scripts src="js/myjsfile.js, css/mystyle.css"></Scripts>
<!-- or be explicit about it -->
<Styles src="css/mystyle.css"></Styles>
Parameters
| Parameter | Default | Description |
|---|---|---|
Src | — | One or more files, comma separated. |
UnloadOnDispose | true | Removes the elements again when the component is disposed. Set to false for libraries that must survive navigation. |
SourceLoadBehaviour | OnAfterRender | OnInitialized, OnInitializedAsync, OnAfterRender or OnAfterRenderAsync. |
SourceLoaded | — | EventCallback<string> raised per file once it finished loading. |
<Scripts src="js/chart.js"
SourceLoadBehaviour="LoadBehaviour.OnInitializedAsync"
UnloadOnDispose="false"
SourceLoaded="OnScriptLoaded" />
@code {
private void OnScriptLoaded(string file) => Console.WriteLine($"{file} is ready");
}
The same thing from code, without a component:
await jsRuntime.LoadFilesAsync("js/chart.js", "css/chart.css");
await jsRuntime.UnloadFilesAsync("js/chart.js");
// wait until a global is really there before using it
if (await jsRuntime.WaitForNamespaceAsync("Chart"))
await jsRuntime.DInvokeVoidAsync(window => window.console.log("Chart is loaded"));
CSS at runtime
AddCss injects a stylesheet from a string, LoadCss reads it from an
EmbeddedResource. Handy for component libraries that ship their styles inside the assembly.
// from a string. The id makes the call repeatable instead of stacking up style tags
await jsRuntime.AddCss(".demo { color: #22d3ee }", "my-styles", skipIfElementExists: true);
// from an <EmbeddedResource> of the calling assembly
await jsRuntime.LoadCss("css/component.css");
// or from a specific assembly
await jsRuntime.LoadCss("css/component.css", typeof(MyComponent).Assembly);
<ItemGroup>
<EmbeddedResource Include="wwwroot\css\component.css" />
</ItemGroup>
Dynamic JS invocation
Normally every JavaScript call needs a named function somewhere in a .js file.
DInvokeVoidAsync and DInvokeAsync<T> skip that: they take a C# lambda,
send its source text to the browser and run it there.
await jsRuntime.DInvokeVoidAsync(window => window.alert("test"));
var answer = await jsRuntime.DInvokeAsync<string>(window => window.prompt("Your name?"));
var title = await jsRuntime.DInvokeAsync<string>(document => document.title);
Passing values
// DOES NOT WORK - currentCount is unknown in the browser
await jsRuntime.DInvokeVoidAsync(window => window.alert(currentCount));
// works: the value is a lambda parameter
await jsRuntime.DInvokeVoidAsync((window, c) => window.alert(c), currentCount);
await jsRuntime.DInvokeVoidAsync((window, c, name) => {
window.alert(c);
window.console.log(name);
}, currentCount, "Flo");
// works too: JSArgument keeps the original variable names
await jsRuntime.DInvokeVoidAsync(window => window.alert(currentCount + " - " + name),
JSArgument.For(currentCount).And(name));
Reusing results
var hash = await jsRuntime.DInvokeAsync<string>(window =>
{
window.alert(currentCount);
return window.location.hash + "_" + currentCount;
}, JSArgument.For(currentCount));
await jsRuntime.DInvokeVoidAsync(document => document.location.hash = hash,
JSArgument.For(hash));
Up to ten parameters are supported, and every overload also accepts a
CancellationToken.
Clipboard
navigator.clipboard only exists in a secure context, which makes it a small pile of
boilerplate in practice. CopyToClipboardAsync uses it when it is available and falls back to
the legacy execCommand path otherwise, so it also works on plain http during development.
var copied = await jsRuntime.CopyToClipboardAsync("Copied with BlazorJS");
if (!copied)
await jsRuntime.AlertAsync("The browser refused the copy.");
// reading always needs a secure context and a user permission
var text = await jsRuntime.ReadClipboardAsync(); // null when denied
ReadClipboardAsync returns null instead of throwing when
the browser denies access, so a denied permission does not need a try/catch.
Dialogs & small helpers
await jsRuntime.AlertAsync("Saved");
var ok = await jsRuntime.ConfirmAsync("Delete this item?");
var name = await jsRuntime.PromptAsync("Your name?", "Flo");
// DOM helpers
var exists = await jsRuntime.IsElementAvailableAsync("my-element-id");
await jsRuntime.RemoveElementAsync("my-element-id");
var scripts = await jsRuntime.GetLoadedScriptsAsync();
// wait for a global that a third party script defines
var ready = await jsRuntime.WaitForNamespaceAsync("google.maps", TimeSpan.FromSeconds(10),
TimeSpan.FromMilliseconds(100));
// hit testing for a mouse event
var inside = await jsRuntime.IsEventWithin(mouseArgs, elementReference);
Modules
// plain import
var module = await jsRuntime.ImportModuleAsync("./js/mymodule.js");
// import and publish it on window, so plain interop calls can reach it
await jsRuntime.ImportModuleAsAsync("./js/mymodule.js", "MyModule");
await jsRuntime.InvokeVoidAsync("MyModule.doSomething");
// import and immediately create a JS object from an exported factory
var (module, instance) = await jsRuntime.ImportModuleAndCreateJsAsync(
"./js/mymodule.js", "initialize", elementReference, dotNetReference);
Save a file
Blazor can read files with <InputFile>, but writing one back out is still a pile of
JavaScript. SaveFileAsync uses the File System Access API when the browser has it, so the
user gets a real save dialog and picks the location, and falls back to a plain download otherwise.
// a string, the mime type defaults to text/plain
await jsRuntime.SaveFileAsync("notes.txt", "Written by BlazorJS");
// bytes
await jsRuntime.SaveFileAsync("report.pdf", pdfBytes, "application/pdf");
// or a stream, nothing is buffered in memory twice
await using var stream = File.OpenRead(path);
var saved = await jsRuntime.SaveFileAsync("export.csv", stream, "text/csv");
if (!saved)
Console.WriteLine("The user closed the save dialog.");
The content is streamed with a DotNetStreamReference, so large files also work in Blazor
Server, where a single SignalR message is capped at 32 KB. The mimeType is optional and only
pre-selects the file type in the save dialog.
false when the user closed the dialog and true when the file
was written or the download started.
| Overload | Content |
|---|---|
SaveFileAsync(fileName, Stream, mimeType, leaveOpen) | Streamed, the stream is disposed unless leaveOpen is set. |
SaveFileAsync(fileName, byte[], mimeType) | Raw bytes. |
SaveFileAsync(fileName, string, mimeType) | UTF-8 text, text/plain by default. |
Event interop
BlazorJSEventInterop<TEventArgs> connects any DOM event to a C# callback. The generic
argument is the event args type the callback receives.
private BlazorJSEventInterop<PointerEventArgs> _jsEvent;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_jsEvent = new BlazorJSEventInterop<PointerEventArgs>(jsRuntime);
// fires when a click happens OUTSIDE of the given selectors - the dropdown case
await _jsEvent.OnBlur(OnClickedOutside, ".my-dropdown");
// or hook any event, optionally scoped to a selector
await _jsEvent.AddEventListener("keydown", OnKeyDown);
await _jsEvent.AddEventListener("scroll", OnScroll, ".my-list");
}
private Task OnClickedOutside(PointerEventArgs args) => CloseDropdown();
// always dispose, it removes the listeners in the browser again
public ValueTask DisposeAsync() => _jsEvent.DisposeAsync();
Resize & visibility
Two observers that are painful to wire up by hand, exposed as the same kind of callback:
OnResize wraps a ResizeObserver, OnVisibilityChanged an
IntersectionObserver. Both are disconnected when the interop instance is disposed.
private BlazorJSEventInterop<ElementSizeArgs> _resize;
private BlazorJSEventInterop<ElementVisibilityArgs> _visibility;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_resize = new BlazorJSEventInterop<ElementSizeArgs>(jsRuntime);
await _resize.OnResize(OnResized, ".my-chart"); // no selector = the whole viewport
_visibility = new BlazorJSEventInterop<ElementVisibilityArgs>(jsRuntime);
await _visibility.OnVisibilityChanged(LoadMore, "#load-more-marker", threshold: 0.5);
}
private Task OnResized(ElementSizeArgs args)
=> InvokeAsync(() => RedrawChart(args.Width, args.Height));
private async Task LoadMore(ElementVisibilityArgs args)
{
if (args.IsVisible)
await LoadNextPage();
}
| Type | Members |
|---|---|
ElementSizeArgs | Width, Height, Top, Left |
ElementVisibilityArgs | IsVisible, Ratio (0 to 1) |
The typical use cases: redrawing a canvas or chart when its container changes, and infinite scrolling or lazy loading with a marker element at the end of a list.
JS base component
BlazorJsBaseComponent<T> is the boilerplate for wrapping a JavaScript library in a
Blazor component: it imports the module, calls its factory function, keeps the JS object reference,
forwards parameter changes and disposes both sides again.
@inherits BlazorJs.BlazorJsBaseComponent<YourComponent>
<div @ref="ElementReference"></div>
public partial class YourComponent
{
protected override string ComponentJsFile() => "./js/YourComponent.js";
protected override string ComponentJsInitializeMethodName() => "initializeYourComponent";
[Parameter] public string SomeGeneralParam { get; set; }
[Parameter, ForJs] public int ParamForJs { get; set; } = 100;
[Parameter, ForJs("differentNameInJs")] public int AnotherParam { get; set; } = 100;
// called automatically whenever a [ForJs] parameter changed
protected override async Task OnJsOptionsChanged()
{
if (JsReference != null)
await JsReference.InvokeVoidAsync("setOptions", MyJsOptions());
}
private object MyJsOptions() => this.AsJsObject(new { configValueWithoutParam = 123 });
// by default only ElementReference and the dotnet reference are passed
public override object[] GetJsArguments()
=> new object[] { ElementReference, CreateDotNetObjectReference(), MyJsOptions() };
}
class YourComponent {
constructor(elementRef, dotNet, options) {
this.elementRef = elementRef;
this.dotnet = dotNet;
this.createWhatever(options);
}
createWhatever(options) {
console.log(options.paramForJs); // [ForJs] lowercases the first char
console.log(options.differentNameInJs); // unless you name it yourself
console.log(options.configValueWithoutParam);
}
setOptions(options) { /* apply the new options */ }
// dispose is called by the base component before the reference is released
dispose() { /* clean up */ }
}
export function initializeYourComponent(elementRef, dotnet, options) {
return new YourComponent(elementRef, dotnet, options);
}
Members
| Member | Description |
|---|---|
JsReference | The created JS object. |
ModuleReference | The imported module. |
ElementReference | The rendered element, bind it with @ref. |
WaitReferenceCreatedAsync() | Completes once the JS object exists — useful from parent components. |
GetJsArguments() | Override to control what the factory function receives. |
OnJsOptionsChanged() | Override to push changed [ForJs] parameters into JS. |
The ForJs attribute
[ForJs] marks the parameters that belong to the JavaScript side. Marked properties are
collected into a plain options object, and a change to any of them triggers
OnJsOptionsChanged.
[Parameter, ForJs] public int Delay { get; set; } // -> options.delay
[Parameter, ForJs("speed")] public int AnimationSpeed { get; set; } // -> options.speed
// changes trigger OnJsOptionsChanged but the value is not part of the options object
[Parameter, ForJs(IgnoreOnParams = true)] public bool Refresh { get; set; }
// build the object, optionally merged with values that are not parameters
var options = this.AsJsObject(new { theme = "dark" });
Enums are serialized with their [Description] value when they have one, which usually
matches the string a JS library expects.
Browser detect
<BrowserDetect> collects what the browser knows about itself into a
BrowserInfo. Architecture and the exact OS version arrive slightly later through the
User-Agent Client Hints API, which is why they have their own callbacks.
<BrowserDetect @bind-browserInfo="@Info"
OSVersionUpdate="v => osVersion = v"
OSArchitectureUpdate="a => architecture = a" />
@code {
public BrowserInfo Info { get; set; }
}
BrowserInfo carries browser name and version, engine, operating system, screen resolution,
time zone, user agent and the IsMobile / IsAndroid / IsIPhone /
IsIPad flags.
API overview
| Member | What it does |
|---|---|
<Scripts> / <Styles> | Load and unload files per page or component. |
LoadFilesAsync / UnloadFilesAsync | The same from code. |
AddCss / LoadCss | Inject css from a string or an embedded resource. |
DInvokeVoidAsync / DInvokeAsync<T> | Run a C# lambda as JavaScript. |
CopyToClipboardAsync / ReadClipboardAsync | Clipboard access with a fallback. |
SaveFileAsync | Save a file through the File System Access API, streamed. |
AlertAsync / ConfirmAsync / PromptAsync | The three browser dialogs. |
WaitForNamespaceAsync / IsNamespaceAvailableAsync | Wait for a global to appear. |
IsElementAvailableAsync / RemoveElementAsync | Small DOM helpers. |
GetLoadedScriptsAsync | All script sources currently in the document. |
IsEventWithin | Whether a mouse event happened inside an element. |
ImportModuleAsync / ImportModuleAsAsync / ImportModuleAndCreateJsAsync | ES module helpers. |
BlazorJSEventInterop<T> | AddEventListener, OnBlur, OnResize, OnVisibilityChanged. |
BlazorJsBaseComponent<T> | Base class for JS wrapper components. |
[ForJs] / AsJsObject | Map component parameters onto a JS options object. |
<BrowserDetect> / BrowserInfo | Browser, engine, OS and screen information. |
Target frameworks
The package multi-targets netstandard2.1 and net6.0 through
net10.0. Every modern target references the matching
Microsoft.AspNetCore.Components.Web version, derived from the target framework itself, so a
new .NET version is a one-line change.
DInvoke*), BlazorJsBaseComponent<T> and the module helpers need
net6.0 or newer.