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.
The one rule
Section titled “The one rule”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.
<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>using Shiny.Blazor.Controls.Splash;
builder.Services.AddShinySplashScreen();@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.
Reporting real startup progress
Section titled “Reporting real startup progress”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.
Replacing the stock template loader
Section titled “Replacing the stock template loader”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.
Customization
Section titled “Customization”Because it renders before Blazor, “customizable” cannot mean a RenderFragment. There are three
tiers, in increasing order of control.
1. Data attributes
Section titled “1. Data attributes”The form shown above — no <script> block of your own. Every option in the table below has a
data- equivalent (minDurationMs → data-min-duration, failSafeMs → data-fail-safe,
blazorLoadProgress → data-blazor-progress, …).
2. A config object
Section titled “2. A config object”<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>3. Bring your own markup
Section titled “3. Bring your own markup”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 (0–1) and --shiny-splash-progress-pct (42%) |
Theming the built-in look
Section titled “Theming the built-in look”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.
shinySplash.show options
Section titled “shinySplash.show options”| 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 |
0–1; 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 |
If your app throws during startup, hide() never runs and the user stares at a spinner forever. The
fail-safe dismisses the splash after failSafeMs and logs a console warning explaining what
happened, so the real error becomes visible.
shinySplash globals
Section titled “shinySplash globals”| Call | Notes |
|---|---|
shinySplash.show(options) |
No-op if already showing |
shinySplash.status(text) |
|
shinySplash.progress(value) |
0–1, 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() |
ISplashScreen
Section titled “ISplashScreen”| Member | Notes |
|---|---|
ValueTask<bool> IsVisibleAsync() |
|
ValueTask SetStatusAsync(string?) |
|
ValueTask SetProgressAsync(double?) |
Clamped to 0–1; 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.
<SplashScreenHost />
Section titled “<SplashScreenHost />”| 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 |
WebAssembly load progress
Section titled “WebAssembly load progress”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.
MAUI Blazor Hybrid
Section titled “MAUI Blazor Hybrid”There are two splashes in a Hybrid app:
- the native
MauiSplashScreen— window creation → WebView ready - 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.
Accessibility
Section titled “Accessibility”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.
AI Skill
Section titled “AI Skill”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install controls@shinyStep 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install controls@shiny

