Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration!How!?

Splash Screen (Blazor Only)

SplashScreen covers the gap between “the browser has the page” and “the app is ready” — the blank white flash every Blazor app shows while the framework boots and the first data loads.

  • NuGet downloads for Shiny.Blazor.Controls
Frameworks
Blazor

The host <div> goes outside #app.

Blazor wipes #app’s contents the instant it attaches the root component — which is well before your app has loaded anything. A splash placed inside #app therefore vanishes too early and you get the blank flash you were trying to remove. Make it a sibling and dismiss it explicitly.

Load order matters too: splash.js must come before blazor.webassembly.js / blazor.webview.js so it paints first.

wwwroot/index.html
<head>
<link href="_content/Shiny.Blazor.Controls/css/shiny-splash.css" rel="stylesheet" />
</head>
<body>
<div id="app">...</div>
<div id="shiny-splash"
data-shiny-splash
data-title="My App"
data-subtitle="by Contoso"
data-logo="img/logo.svg"
data-spinner="ring"
data-status="Starting up…"
data-min-duration="600"
data-fade="300"></div>
<script src="_content/Shiny.Blazor.Controls/splash.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
Program.cs
using Shiny.Blazor.Controls.Splash;
builder.Services.AddShinySplashScreen();
MainLayout.razor
@using Shiny.Blazor.Controls.Splash
<SplashScreenHost />

SplashScreenHost renders nothing — it exists to dismiss the splash once the app is up. That is already a complete, working splash.

The interesting version holds the splash through your actual startup work instead of dropping the user into an empty shell:

<SplashScreenHost Until="StartupAsync" />
@code {
[Inject] ISplashScreen Splash { get; set; } = default!;
async Task StartupAsync()
{
await Splash.SetStatusAsync("Loading accounts…");
await Splash.SetProgressAsync(0.3);
await this.accounts.LoadAsync();
await Splash.SetStatusAsync("Refreshing repositories…");
await Splash.SetProgressAsync(0.8);
await this.repos.RefreshAsync();
}
}

Until runs after the first render and the splash is dismissed in a finally — a startup exception still ends up visible instead of trapped behind the splash.

To dismiss it from somewhere else entirely, set AutoHide="false" and call ISplashScreen.HideAsync() yourself.

The WebAssembly template ships its own loading UI inside #app:

<div id="app">
<svg class="loading-progress">…</svg>
<div class="loading-progress-text"></div>
</div>

That placement is exactly why it only ever covers the framework download — Blazor wipes it the moment it attaches, so you still get download → blank → pop. SplashScreen replaces it outright: empty #app, put the host beside it, and turn on blazorLoadProgress to keep the percentage behaviour (both read the same --blazor-load-percentage). The difference is that the splash now spans both phases — the download and your startup work.

<div id="app"></div>
<div id="shiny-splash" data-shiny-splash data-blazor-progress="true" data-title="My App"></div>

The Blazor Hybrid template’s <div id="app">Loading...</div> is redundant for the same reason. Leave #blazor-error-ui alone — it is unrelated, and the fail-safe is what makes a boot error reachable instead of hidden behind a spinner.

Because it renders before Blazor, “customizable” cannot mean a RenderFragment. There are three tiers, in increasing order of control.

The form shown above — no <script> block of your own. Every option in the table below has a data- equivalent (minDurationMsdata-min-duration, failSafeMsdata-fail-safe, blazorLoadProgressdata-blazor-progress, …).

<div id="shiny-splash"></div>
<script src="_content/Shiny.Blazor.Controls/splash.js"></script>
<script>
shinySplash.show({
title: 'My App',
logo: 'img/logo.svg',
spinner: 'dots',
accent: '#7C3AED',
background: '#0F172A',
foreground: '#F8FAFC',
minDurationMs: 400,
fadeMs: 250
});
</script>

If the host <div> already has children, the script leaves them completely alone — it only owns show / status / progress / fade / hide. This is the right tier for anything bespoke: an animated brand mark, a video, whatever you like.

<div id="shiny-splash" data-shiny-splash>
<div class="my-brand">
<svg><!-- your animated logo --></svg>
<p data-shiny-splash-status>Starting up…</p>
<div class="my-track">
<div data-shiny-splash-progress-fill></div>
</div>
<span data-shiny-splash-percent></span>
</div>
</div>
Hook What the script sets
[data-shiny-splash-status] textContent — the status text
[data-shiny-splash-progress-fill] style.width — e.g. 42%
[data-shiny-splash-percent] textContent — e.g. 42%
CSS vars on the host --shiny-splash-progress (01) and --shiny-splash-progress-pct (42%)

Four CSS custom properties, each defaulting to the matching Shiny theme token: --shiny-splash-bg, --shiny-splash-fg, --shiny-splash-accent, --shiny-splash-muted. Set them via the background / foreground / accent / muted options, or in your own CSS.

Option Type Default Description
hostId string 'shiny-splash' Element id to use; created and appended to <body> if missing
title string? null Headline (ignored when you supply your own markup)
subtitle string? null Secondary line
logo string? null Image src
logoAlt string '' Image alt text
spinner string 'ring' ring | dots | bar | pulse | none
status string? null Initial status line
progress number? null 01; null means indeterminate
background / foreground / accent / muted string? theme tokens Written as --shiny-splash-* CSS vars on the host
cssClass string? null Extra class on the host
minDurationMs number 0 Minimum time on screen — stops a warm start flickering
fadeMs number 250 Fade-out duration
failSafeMs number 30000 Auto-hide if hide() is never called (0 disables)
blazorLoadProgress bool false WASM only — mirror Blazor’s --blazor-load-percentage into the progress bar
removeOnHide bool true Remove the host from the DOM on hide; false just hides it so it can be re-shown
lockScroll bool true Adds shiny-splash-locked to <html> while visible
Call Notes
shinySplash.show(options) No-op if already showing
shinySplash.status(text)
shinySplash.progress(value) 01, or null for indeterminate. Also cancels the blazorLoadProgress tracker — an explicit call always wins.
shinySplash.hide(fadeMs?) Idempotent; honours minDurationMs. Fires a shiny-splash-hidden event on document.
shinySplash.isVisible()
Member Notes
ValueTask<bool> IsVisibleAsync()
ValueTask SetStatusAsync(string?)
ValueTask SetProgressAsync(double?) Clamped to 01; null = indeterminate
ValueTask HideAsync(int? fadeMs = null) Idempotent

Every call is a best-effort no-op if splash.js was never referenced — forgetting the <script> tag should not take your app down at startup.

Parameter Type Default Description
AutoHide bool true Hide once the app has rendered
Until Func<Task>? null Startup work awaited before hiding; exceptions surface, but the splash is dismissed first
Delay int 0 Extra milliseconds to hold after ready
FadeDuration int? null Overrides the fade configured at show time
Hidden EventCallback Raised after dismissal

During the initial download Blazor WASM writes --blazor-load-percentage onto :root. Set blazorLoadProgress and the splash mirrors it into the progress bar, so the bar reflects the real runtime download rather than a fake animation. The tracker stops at 100% (or on your first explicit SetProgressAsync) and hands the bar over to your own startup reporting.

This is meaningless outside WASM — Blazor Server and Blazor Hybrid have no download phase, and the variable simply never appears.

There are two splashes in a Hybrid app:

  1. the native MauiSplashScreen — window creation → WebView ready
  2. this one — WebView → Blazor booted

Give them the same background colour, along with <body> and the BlazorWebView background, or you get a white flash at each seam. Skip blazorLoadProgress; drive the bar from your own startup work instead.

The host is role="progressbar" with aria-live="polite", aria-busy, and aria-valuenow tracking determinate progress. Under prefers-reduced-motion every indicator degrades to a static, legible shape rather than being animated to nothing, and the fade is disabled.

claude plugin marketplace add shinyorg/skills
claude plugin install controls@shiny
copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install controls@shiny
View controls Plugin