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.

Run the sample with QuickRun NuGet version NuGet downloads MIT license

BlazorJS is a small package with no dependencies beyond the Blazor framework itself. It works in Blazor Server, Blazor WebAssembly and Blazor Web Apps.

Try it. The repository ships a sample app with a live page for every feature. With QuickRun it is one click, otherwise 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

cli
dotnet add package BlazorJS

Add the namespace to your _Imports.razor:

_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.

razor
<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

ParameterDefaultDescription
SrcOne or more files, comma separated.
UnloadOnDisposetrueRemoves the elements again when the component is disposed. Set to false for libraries that must survive navigation.
SourceLoadBehaviourOnAfterRenderOnInitialized, OnInitializedAsync, OnAfterRender or OnAfterRenderAsync.
SourceLoadedEventCallback<string> raised per file once it finished loading.
razor
<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:

csharp
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.

csharp
// 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);
csproj
<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.

csharp
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

Only the text of the lambda is transferred. Variables of your component do not exist in the browser, so they have to be passed as arguments.
csharp
// 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

csharp
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.

csharp
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
Note: ReadClipboardAsync returns null instead of throwing when the browser denies access, so a denied permission does not need a try/catch.

Dialogs & small helpers

csharp
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

csharp
// 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.

csharp
// 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.

Call it from a user interaction. Browsers reject a save dialog that no click asked for. The return value is false when the user closed the dialog and true when the file was written or the download started.
OverloadContent
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.

csharp
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();
Selector not there yet? If the element does not exist at the time of the call, a MutationObserver waits for it and attaches the listener as soon as Blazor rendered it.

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.

csharp
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();
}
TypeMembers
ElementSizeArgsWidth, Height, Top, Left
ElementVisibilityArgsIsVisible, 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.

YourComponent.razor
@inherits BlazorJs.BlazorJsBaseComponent<YourComponent>

<div @ref="ElementReference"></div>
YourComponent.razor.cs
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() };
}
./js/YourComponent.js
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

MemberDescription
JsReferenceThe created JS object.
ModuleReferenceThe imported module.
ElementReferenceThe 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.

csharp
[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.

razor
<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

MemberWhat it does
<Scripts> / <Styles>Load and unload files per page or component.
LoadFilesAsync / UnloadFilesAsyncThe same from code.
AddCss / LoadCssInject css from a string or an embedded resource.
DInvokeVoidAsync / DInvokeAsync<T>Run a C# lambda as JavaScript.
CopyToClipboardAsync / ReadClipboardAsyncClipboard access with a fallback.
SaveFileAsyncSave a file through the File System Access API, streamed.
AlertAsync / ConfirmAsync / PromptAsyncThe three browser dialogs.
WaitForNamespaceAsync / IsNamespaceAvailableAsyncWait for a global to appear.
IsElementAvailableAsync / RemoveElementAsyncSmall DOM helpers.
GetLoadedScriptsAsyncAll script sources currently in the document.
IsEventWithinWhether a mouse event happened inside an element.
ImportModuleAsync / ImportModuleAsAsync / ImportModuleAndCreateJsAsyncES module helpers.
BlazorJSEventInterop<T>AddEventListener, OnBlur, OnResize, OnVisibilityChanged.
BlazorJsBaseComponent<T>Base class for JS wrapper components.
[ForJs] / AsJsObjectMap component parameters onto a JS options object.
<BrowserDetect> / BrowserInfoBrowser, 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.

netstandard2.1 exists for old Blazor 3.1 projects. The dynamic invocation API (DInvoke*), BlazorJsBaseComponent<T> and the module helpers need net6.0 or newer.