Lumeo

File Viewer

Universal preview surface — detects the file type from MIME / extension and renders inline: PDF, images, video, audio, Markdown, source code, JSON, CSV, and plain text. Unknown types fall back to a download CTA. One component replaces a stack of bespoke per-type renderers.

Installation

dotnet add package Lumeo

One-time app setup (AddLumeo(), CSS & JS) is covered in the installation guide.

Usage

@using Lumeo

<FileViewer />
Tested Tier 3 · A11y + behavior
all components →
Render Behavior A11y Keyboard Scale E2E

30 tests across 6 files. Auto-generated from the test suite.

When to Use

  • Document or attachment lists where each row links to a heterogeneous file (PDF report, screenshot, CSV export, README)
  • File managers / DMS surfaces that need a quick preview pane next to the tree
  • Audit / compliance flows where the user has to inspect the actual content before approving an action
  • Ticketing systems where customers attach mixed-format evidence
  • Anywhere consumers would otherwise build a switch over Pdf / Image / Video / Markdown viewers by hand

How Detection Works

Resolution runs in this order — each step wins over the next:

  1. Explicit Kind parameter (anything other than Auto short-circuits)
  2. Explicit MimeType parameter (most reliable for blob URLs / signed URLs where the extension is hidden)
  3. Optional HEAD request Content-Type — off by default (AutoHead="true" to enable) because many CDNs reject HEAD with 405
  4. URL extension — the last-resort guess from the path segment
sample.svg
sample.svg
README.md

Lumeo FileViewer

A universal preview surface for Blazor — drop in a URL, get the right inline viewer.

Highlights

  • Auto-detect by MIME, optional HEAD, or URL extension
  • Built-in renderers for PDF, image, video, audio, markdown, source code, JSON, CSV, and plain text
  • Auth-aware fetches via HttpClient parameter or ConfigureRequest delegate
  • Safety caps — MaxBytes for text fetches, MaxCsvRows for tabular data
  • Pluggable per-kind overrides via CustomRenderers

Quick start

<FileViewer Src="@DocumentUrl"
            FileName="@Document.Name"
            OnLoaded="HandleLoaded"
            Class="h-[480px]" />

Resolution order

  1. Explicit Kind parameter (anything other than Auto wins)
  2. Explicit MimeType parameter
  3. HEAD request Content-Type (when AutoHead="true")
  4. URL extension

Markdown is rendered with .DisableHtml() — raw <script> and <iframe> in user-supplied markdown never reach the DOM.

For more, see the docs site.

data.csv
componentcategorynuget_packagefirst_release
AccordionNavigationLumeo1.0.0
DataGridData DisplayLumeo.DataGrid2.0.0
FileViewerData DisplayLumeo.FileViewer3.2.5
MapData DisplayLumeo.Maps3.2.0
PdfViewerData DisplayLumeo.PdfViewer3.1.0
QueryBuilderFormsLumeo2.1.0
RichTextEditorFormsLumeo.Editor2.0.0
SchedulerData DisplayLumeo.Scheduler2.0.0
ToastFeedbackLumeo1.0.0
TreeViewData DisplayLumeo1.0.0
Program.cs
using Lumeo;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// Standard HttpClient registration — FileViewer picks this up automatically
// for fetching text-based files (Markdown, JSON, CSV, Code, Text).
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});

builder.Services.AddLumeo();

await builder.Build().RunAsync();

/ 14
100%

Force a kind when the URL has no extension (typical for blob / signed URLs).

/ 14
100%

Provide MIME from your backend metadata response — most reliable for opaque URLs.

screenshot.svg
screenshot.svg
README.md

Lumeo FileViewer

A universal preview surface for Blazor — drop in a URL, get the right inline viewer.

Highlights

  • Auto-detect by MIME, optional HEAD, or URL extension
  • Built-in renderers for PDF, image, video, audio, markdown, source code, JSON, CSV, and plain text
  • Auth-aware fetches via HttpClient parameter or ConfigureRequest delegate
  • Safety caps — MaxBytes for text fetches, MaxCsvRows for tabular data
  • Pluggable per-kind overrides via CustomRenderers

Quick start

<FileViewer Src="@DocumentUrl"
            FileName="@Document.Name"
            OnLoaded="HandleLoaded"
            Class="h-[480px]" />

Resolution order

  1. Explicit Kind parameter (anything other than Auto wins)
  2. Explicit MimeType parameter
  3. HEAD request Content-Type (when AutoHead="true")
  4. URL extension

Markdown is rendered with .DisableHtml() — raw <script> and <iframe> in user-supplied markdown never reach the DOM.

For more, see the docs site.

archive.zip

Preview unavailable

This file type cannot be previewed inline.

does-not-exist.csv
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset=utf-8 />
<meta name=viewport content=width=device-width, initial-scale=1.0 />
<title>Lumeo &mdash; Blazor Component Library</title>
<base href=/ />
<link href=_framework/dotnet.s37zgmmvng.js rel=preload as=script fetchpriority=high crossorigin=anonymous integrity=sha256-uFYJOrc6tFgQIPCP4PGyFROzIsFyAKB5CTJ4FpwGyrY= />
<!-- Preload the Blazor runtime JS so it downloads in parallel with HTML parsing -->
<link rel=preload as=script href=_framework/blazor.webassembly.zxhwjtv6sc.js crossorigin>
<link rel=icon type=image/svg+xml href=favicon.svg />
<!-- SEO + social sharing -->
<meta name=description content=Lumeo — 169 accessible Blazor components inspired by shadcn/ui. AI primitives, motion, full DataGrid, a Flow node canvas, 14 locales, Tailwind CSS v4. MIT, .NET 10. />
<meta name=theme-color content=#0a0a0a />
<!-- Open Graph (Discord LinkedIn Slack Facebook) -->
<meta property=og:site_name content=Lumeo />
<meta property=og:type content=website />
<meta property=og:title content=Lumeo — Blazor component library you'd actually want to ship />
<meta property=og:description content=169 accessible Blazor components across 13 packages — 712 KB core + opt-in satellites (Charts, DataGrid, Editor, Scheduler, Gantt, Flow, PdfViewer, Maps). Tailwind v4 native, AI primitives, MIT, .NET 10. />
<meta property=og:url content=https://lumeo.nativ.sh />
<meta property=og:image content=https://lumeo.nativ.sh/social-preview.png />
<meta property=og:image:width content=1280 />
<meta property=og:image:height content=640 />
<!-- Twitter / X summary card -->
<meta name=twitter:card content=summary_large_image />
<meta name=twitter:title content=Lumeo — Blazor component library />
<meta name=twitter:description content=169 components across 13 packages. 712 KB core + opt-in satellites. Tailwind v4, AI, 14 locales, MIT. />
<meta name=twitter:image content=https://lumeo.nativ.sh/social-preview.png />
<!-- Consent banner FOUC guard — runs synchronously BEFORE Blazor boots so the
banner never flashes for users who already gave (or rejected) consent.
Adds .lumeo-consent-decided to <html> if the consent localStorage entry exists;
a CSS rule below force-hides any element with class .lumeo-consent-banner. -->
<script>
(function () {
try {
if (localStorage.getItem('lumeo:consent:v1')) {
document.documentElement.classList.add('lumeo-consent-decided');
}
} catch (e) { /* private mode / cookies blocked — fall through banner shows once */ }
})();
</script>
<!-- Self-host ECharts + its extension plugins (GDPR). Lumeo.Charts lazy-loads
ECharts and the LiquidFill / WordCloud plugins on first render and by
default from cdn.jsdelivr.net — third-party requests that would disclose
the visitor's IP to a CDN before any consent on every chart-bearing page.
We point the library's documented window.lumeoCdn overrides at version-pinned
copies served from our own origin (see wwwroot/lib/lumeo-vendor/ all
Apache-2.0 / MIT). Each path carries the pinned version so the immutable
year-long cache in _headers is only ever hit for an exact version — a bump
changes the URL. Root-absolute so they resolve on nested routes like
/components/charts/bar. Runs synchronously before Blazor boots. -->
<script>
window.lumeoCdn = window.lumeoCdn || {};
window.lumeoCdn.echarts = '/lib/lumeo-vendor/[email protected]/echarts.min.js';
window.lumeoCdn.echartsLiquidfill = '/lib/lumeo-vendor/[email protected]/echarts-liquidfill.min.js';
window.lumeoCdn.echartsWordcloud = '/lib/lumeo-vendor/[email protected]/echarts-wordcloud.min.js';
</script>
<!-- Splash screen — plain CSS loads before Tailwind -->
<style>
html.lumeo-consent-decided .lumeo-consent-banner { display: none !important; }
.lumeo-splash {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: #ffffff; color: hsl(240 5.9% 10%);
transition: opacity 0.4s ease;
}
.lumeo-splash--hide { opacity: 0; pointer-events: none; }
html.dark .lumeo-splash { background: hsl(240 10% 3.9%); color: hsl(0 0% 98%); }
.lumeo-splash-inner { display: flex; flex-direction: column; align-items: center; gap: 2.5rem; text-align: center; }
.lumeo-splash-logo-wrap { position: relative; width: 88px; height: 88px; display: flex; align-items: center; justify-content: center; }
.lumeo-splash-ripple { position: absolute; inset: 0; border-radius: 50%; border: 1px solid currentColor; opacity: 0; }
@keyframes splash-ripple { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(7); opacity: 0; } }
.lumeo-splash-ripple.r1 { animation: splash-ripple 5.5s cubic-bezier(0 0.45 0.2 1) 1.2s infinite; }
.lumeo-splash-ripple.r2 { animation: splash-ripple 5.5s cubic-bezier(0 0.45 0.2 1) 3s infinite; }
.lumeo-splash-ripple.r3 { animation: splash-ripple 5.5s cubic-bezier(0 0.45 0.2 1) 4.8s infinite; }
.lumeo-splash-svg { position: relative; z-index: 1; width: 88px; height: 88px; animation: splash-logo-in 1.4s cubic-bezier(0.16 1 0.3 1) both; }
@keyframes splash-logo-in { from { opacity: 0; transform: scale(0.65); } to { opacity: 1; transform: scale(1); } }
.lumeo-splash-ring { animation: splash-ring-breathe 4.5s ease-in-out 1.4s infinite; }
@keyframes splash-ring-breathe { 0% 100% { opacity: 0.28; } 50% { opacity: 0.65; } }
.lumeo-splash-dot { transform-origin: 16px 16px; animation: splash-dot-breathe 4.5s ease-in-out 1.4s infinite; }
@keyframes splash-dot-breathe { 0% 100% { transform: scale(1); opacity: 0.7; } 50% { transform: scale(1.22); opacity: 1; } }
.lumeo-splash-texts { display: flex; flex-direction: column; align-items: center; gap: 0.55rem; }
.lumeo-splash-name { font-family: system-ui -apple-system sans-serif; font-size: 2rem; font-weight: 600; letter-spacing: 0.02em; line-height: 1; animation: splash-name-in 1s cubic-bezier(0.16 1 0.3 1) 0.8s both; }
@keyframes splash-name-in { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
.lumeo-splash-tagline { font-family: system-ui -apple-system sans-serif; font-size: 0.68rem; letter-spacing: 0.18em; text-transform: uppercase; animation: splash-tagline-in 1s cubic-bezier(0.16 1 0.3 1) 1.3s both; }
@keyframes splash-tagline-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 0.35; transform: translateY(0); } }
/* Deferred-hydration boot indicator (landing only). The '/' snapshot has
NO full-screen splash — it is readable marketing content — so when a
user gesture triggers the WASM boot we show a tiny unobtrusive corner
pill for feedback. Removed by js/docs.js signalBlazorReady the moment
the app is interactive. */
.lumeo-boot-pill {
position: fixed; bottom: 1rem; right: 1rem; z-index: 9998;
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 0.75rem;
font-family: system-ui -apple-system sans-serif; font-size: 0.75rem; font-weight: 500;
color: hsl(240 4% 34%);
background: rgba(255 255 255 0.9); border: 1px solid rgba(0 0 0 0.08);
border-radius: 9999px; box-shadow: 0 2px 8px rgba(0 0 0 0.08);
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
animation: lumeo-boot-pill-in 0.25s ease both;
}
html.dark .lumeo-boot-pill { color: hsl(0 0% 80%); background: rgba(23 23 23 0.9); border-color: rgba(255 255 255 0.1); }
.lumeo-boot-pill-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: currentColor; animation: lumeo-boot-pill-pulse 1s ease-in-out infinite; }
@keyframes lumeo-boot-pill-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes lumeo-boot-pill-pulse { 0% 100% { opacity: 0.4; } 50% { opacity: 1; } }
/* While the landing is prerendered-but-not-yet-hydrated neutralise the
not-yet-live <button> controls in the static header (real <a> CTAs keep
working — they navigate without JS). A pointerdown still bubbles to the
document listener and boots so a click both starts hydration and once
live does the right thing. Cleared with the class on signalBlazorReady. */
html.lumeo-landing-pending #app button { pointer-events: none; }
</style>
<!-- Pre-built Tailwind CSS (compiled from Styles/tailwind.css via @tailwindcss/cli).
`?v=91ba491` is rewritten by the deploy workflow with the git SHA so the browser
re-fetches whenever the CSS source changes — no more stale cached tailwind.out.css. -->
<link rel=stylesheet href=css/tailwind.out.css?v=91ba491 />
<!-- Lumeo theme CSS -->
<link rel=stylesheet href=_content/Lumeo/css/lumeo.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_blue.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_green.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_rose.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_orange.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_violet.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_amber.css?v=91ba491 />
<link rel=stylesheet href=_content/Lumeo/css/themes/_teal.css?v=91ba491 />
<!-- Self-hosted fonts (GDPR compliant) -->
<link rel=stylesheet href=css/fonts.css?v=91ba491 />
<!-- App styles -->
<link rel=stylesheet href=css/app.css?v=91ba491 />
<script type=importmap>{
imports: {
./_framework/blazor.webassembly.js: ./_framework/blazor.webassembly.zxhwjtv6sc.js
./_framework/dotnet.native.js: ./_framework/dotnet.native.baur8jsal5.js
./_framework/dotnet.runtime.js: ./_framework/dotnet.runtime.v06hirbjsv.js
./_framework/dotnet.js: ./_framework/dotnet.s37zgmmvng.js
}
scopes: {}
integrity: {
./_framework/blazor.webassembly.js: sha256-1xlHuu1iNOJiMMz2BCq95uekwHf6pdpTcgVNKVBboPs=
./_framework/blazor.webassembly.zxhwjtv6sc.js: sha256-1xlHuu1iNOJiMMz2BCq95uekwHf6pdpTcgVNKVBboPs=
./_framework/dotnet.js: sha256-uFYJOrc6tFgQIPCP4PGyFROzIsFyAKB5CTJ4FpwGyrY=
./_framework/dotnet.native.baur8jsal5.js: sha256-E0ZofiM36/dxfSevqytizrm4JslJrXM5d6XWXbvIVdo=
./_framework/dotnet.native.js: sha256-E0ZofiM36/dxfSevqytizrm4JslJrXM5d6XWXbvIVdo=
./_framework/dotnet.runtime.js: sha256-QbnqrZGHtGq7wudS17/AYEPpH9JkphrsyVllD6JHmds=
./_framework/dotnet.runtime.v06hirbjsv.js: sha256-QbnqrZGHtGq7wudS17/AYEPpH9JkphrsyVllD6JHmds=
./_framework/dotnet.s37zgmmvng.js: sha256-uFYJOrc6tFgQIPCP4PGyFROzIsFyAKB5CTJ4FpwGyrY=
}
}</script>
<!-- Algolia search — keys injected at build time by the deploy workflow. -->
<meta name=algolia-app-id content=HBUUWJXGUJ />
<meta name=algolia-search-key content=63e2253db63054def494cdb450f4e08d />
</head>
<body class=bg-background text-foreground>
<div id=app>
<!-- ===================== STATIC FIRST-PAINT HERO =====================
Plain HTML using the same Tailwind classes as Pages/Home.razor's hero
so it paints the real headline the instant the CSS lands (no WASM boot
on the critical path) and LCP fires immediately. Blazor replaces #app
with the identical Blazor-rendered hero on mount so the swap is
invisible. Dark mode is already correct: theme.js adds `.dark` to
<html> synchronously before first paint and every class below is a
CSS-var-backed token that flips with it. Doubles as the boot-failure
fallback (all CTAs are real anchors). -->
<div class=min-h-screen bg-background>
<div class=fixed top-0 inset-x-0 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80>
<header class=flex h-16 w-full items-center gap-4 px-4 sm:px-6>
<a href=/ class=flex items-center space-x-2 font-bold text-lg text-foreground no-underline shrink-0>
<svg width=28 height=28 viewBox=0 0 32 32 fill=none xmlns=http://www.w3.org/2000/svg>
<circle cx=16 cy=16 r=9 fill=none stroke=currentColor stroke-width=2 opacity=0.55 />
<circle cx=16 cy=16 r=3 fill=currentColor />
</svg>
<span>Lumeo</span>
</a>
<nav class=hidden md:flex items-center space-x-1 text-sm font-medium ml-2 aria-label=Primary navigation>
<a href=docs/introduction class=px-3 py-2 rounded-md text-muted-foreground no-underline>Docs</a>
<a href=components class=px-3 py-2 rounded-md text-muted-foreground no-underline>Components</a>
<a href=blocks class=px-3 py-2 rounded-md text-muted-foreground no-underline>Blocks</a>
</nav>
<div class=flex-1></div>
<a href=https://github.com/Brain2k-0005/Lumeo class=inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground no-underline aria-label=Lumeo on GitHub>
<svg viewBox=0 0 24 24 class=h-4 w-4 fill=currentColor aria-hidden=true>
<path d=M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z />
</svg>
</a>
</header>
</div>
<div class=pt-16>
<section class=relative overflow-hidden>
<div class=absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--border))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--border))_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_60%_at_50%_0%,#000_60%,transparent_110%)] pointer-events-none opacity-30></div>
<div class=relative mx-auto w-full max-w-[1400px] px-4 pt-8 pb-6>
<div class=flex justify-center mb-6>
<a href=docs/changelog class=no-underline group>
<div class=inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/80 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur-sm>
<span class=inline-flex items-center rounded-sm bg-primary px-1.5 h-4 text-[10px] leading-none font-medium text-primary-foreground>v5.10</span>
<span class=hidden sm:inline>IconPicker picker sizes DataGrid cell editing and a field report worked through</span>
<span class=sm:hidden>New in 5.10: IconPicker</span>
<svg class=h-3 w-3 viewBox=0 0 24 24 fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><path d=M5 12h14 /><path d=m12 5 7 7-7 7 /></svg>
</div>
</a>
</div>
<div class=text-center max-w-3xl mx-auto>
<h1 class=text-4xl sm:text-5xl md:text-6xl font-bold tracking-[-0.02em] leading-[1.05] text-foreground>
Own your Blazor UI.
</h1>
<p class=mt-4 mx-auto max-w-2xl text-base sm:text-lg leading-relaxed text-muted-foreground>
Composable accessible Blazor components with thoughtful defaults. Vendor the source theme with CSS variables ship without lock-in.
</p>
<div class=flex flex-wrap justify-center items-center gap-3 mt-8>
<a href=docs/introduction class=inline-flex h-10 items-center gap-2 justify-center rounded-lg bg-primary px-6 text-sm font-semibold text-primary-foreground no-underline>
Get Started
<svg class=h-4 w-4 viewBox=0 0 24 24 fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><path d=M5 12h14 /><path d=m12 5 7 7-7 7 /></svg>
</a>
<a href=components class=inline-flex h-10 items-center justify-center rounded-lg border border-border/60 bg-background px-6 text-sm font-semibold text-foreground no-underline>
View Components
</a>
</div>
</div>
</div>
</section>
<!-- Live-example placeholder: same box the real Dashboard01 teaser (mounted
through IdleMount + a Skeleton placeholder in Home.razor cropped to a
fixed-height frame) renders into so nothing shifts when Blazor takes over.
No dashboard markup here on purpose — it never has to match dashboard-01's
DOM only its footprint. -->
<section class=py-10 sm:py-14>
<div class=relative mx-auto w-full max-w-[1400px] px-4>
<div class=h-[440px] md:h-[640px] w-full rounded-[var(--radius-lg)] border border-border/40 bg-primary/10></div>
<p class=mt-4 text-center text-sm text-muted-foreground>
Full applications built with Lumeo:
<a href=demos/saas class=text-foreground no-underline hover:underline>Northlight</a> (demos/saas)
&middot;
<a href=demos/enterprise class=text-foreground no-underline hover:underline>Meridian Ops</a> (demos/enterprise)
&middot;
<a href=blocks/dashboard class=text-foreground no-underline hover:underline>Open this dashboard</a>
</p>
</div>
</section>
</div>
</div>
</div>
<div id=blazor-error-ui data-nosnippet style=display:none; position:fixed; bottom:0; width:100%; background:#ffcccc; padding:0.6rem 1rem; z-index:1000;>
An unhandled error has occurred.
<a href=. class=reload>Reload</a>
<span class=dismiss style=cursor:pointer; margin-left:1rem;>X</span>
</div>
<!-- theme.js runs synchronously to apply dark/light class before first paint
(avoids flash of wrong theme). All other scripts are deferred. -->
<script src=_content/Lumeo/js/theme.js></script>
<script src=js/docs.js defer></script>
<script src=js/nav-scroll.js defer></script>
<script src=js/algolia-search.js defer></script>
<script src=js/constellation.js defer></script>
<!-- Manual start (autostart=false): the WASM runtime boot — the dominant
Total-Blocking-Time source in a Lighthouse trace — is deferred behind a
route-aware trigger policy (see the controller below) instead of running
on the critical path. The blazor.webassembly.js loader still downloads
eagerly (preloaded in <head>) so Blazor.start() is instant when triggered. -->
<script src=_framework/blazor.webassembly.zxhwjtv6sc.js autostart=false></script>
<script>
// ============================ DEFERRED HYDRATION ============================
// Trigger policy:
// • Any route other than '/' → boot immediately (docs/component pages need
// interactivity fast; a full-screen splash injected into the prerendered
// snapshot covers the dead DOM until the app signals interactive).
// • '/' (the landing) → boot on FIRST real user intent. pointerdown /
// keydown / touchstart fire instantly; pointermove / wheel / scroll require
// ~150ms of *sustained* movement so Lighthouse's synthetic near-instant
// quiet trace never trips it. A hard 10s fallback boots anyway so the page
// always comes alive even with zero interaction.
// • During prerender (window.__LUMEO_PRERENDER__ set by the crawler before
// any page script runs) → boot immediately on every route '/' included so
// the crawler snapshots the hydrated DOM without waiting on the fallback.
(function () {
var path = (location.pathname || '/').replace(/\/+$/ '') || '/';
var isLanding = path === '/';
var prerender = !!window.__LUMEO_PRERENDER__;
var booted = false;
var fallback = 0;
var opts = { passive: true capture: true };
var instant = ['pointerdown' 'keydown' 'touchstart'];
var moved = ['pointermove' 'wheel' 'scroll'];
var moveStart = 0;
function showPill() {
if (document.querySelector('.lumeo-boot-pill')) return;
var pill = document.createElement('div');
pill.className = 'lumeo-boot-pill';
pill.setAttribute('role' 'status');
pill.setAttribute('aria-live' 'polite');
pill.innerHTML = '<span class=lumeo-boot-pill-dot></span>Starting…';
(document.body || document.documentElement).appendChild(pill);
// Defensive self-clear; docs.js signalBlazorReady normally removes it.
setTimeout(function () { if (pill && pill.parentNode) pill.remove(); } 20000);
}
function teardown() {
clearTimeout(fallback);
instant.forEach(function (t) { window.removeEventListener(t onInstant opts); });
moved.forEach(function (t) { window.removeEventListener(t onMove opts); });
}
function start() {
if (booted) return;
booted = true;
teardown();
if (isLanding && !prerender) showPill();
try {
var p = window.Blazor && window.Blazor.start ? window.Blazor.start() : null;
if (p && typeof p.catch === 'function') p.catch(function () {});
} catch (e) { /* boot failure surfaces via #blazor-error-ui */ }
}
function onInstant() { start(); }
function onMove() {
var now = Date.now();
// Restart the window if movement paused — a lone synthetic event
// won't be followed ~150ms later by a second one in the same stream.
if (!moveStart || now - moveStart > 500) { moveStart = now; return; }
if (now - moveStart >= 150) start();
}
// Immediate paths: prerender crawl or any non-landing route.
if (prerender || !isLanding) { start(); return; }
// Landing: defer until real intent. Mark pending so CSS can neutralise
// the static header's not-yet-live <button> controls.
document.documentElement.classList.add('lumeo-landing-pending');
instant.forEach(function (t) { window.addEventListener(t onInstant opts); });
moved.forEach(function (t) { window.addEventListener(t onMove opts); });
fallback = setTimeout(start 10000);
})();
// Safety net: the splash / pill / pending state is normally cleared by
// js/docs.js signalBlazorReady the moment the app is interactive. If the
// runtime never boots (network / boot failure) still reveal the page after
// a timeout so users aren't stranded with the error UI hidden beneath.
setTimeout(function () {
var s = document.querySelector('.lumeo-splash');
if (s) { s.classList.add('lumeo-splash--hide'); setTimeout(function () { s.remove(); } 450); }
var pill = document.querySelector('.lumeo-boot-pill');
if (pill) pill.remove();
document.documentElement.classList.remove('lumeo-landing-pending');
} 15000);
</script>
</body>
</html>
sample.svg
sample.svg
sample.svg
Image
sample.svg
sample.svg
sample.svg

Auth-Aware Fetching

Text-based kinds (Markdown, Code, JSON, CSV, Text) are fetched via HttpClient. For signed-but-not-presigned URLs you have three knobs:

  • HttpClient="..." — pass a pre-configured client (with handlers attached) directly
  • ConfigureRequest="..." — mutate the outgoing HttpRequestMessage per call (typical for Authorization headers)
  • Register HttpClient in DI — picked up automatically (the standard Blazor WASM pattern)

Safety Limits

  • MaxBytes (default 10 MB) — refused upfront via Content-Length when known, truncated mid-stream otherwise. No 100 MB OOMs.
  • MaxCsvRows (default 1000) — CSV parser stops after this many rows and adds a truncation notice.
  • Markdown is rendered with .DisableHtml() — raw <script> / <iframe> in user-supplied .md never reach the DOM.
  • SVG renders via <img> (not inline) — embedded scripts in SVG can't execute.
  • In-flight fetches are cancelled via CancellationToken when Src changes.

Accessibility

  • Body region wears role="document" with an aria-label derived from the file name or kind.
  • Loading + error states announce via the default Spinner / EmptyState components (both come pre-wired with aria-live).
  • Video / audio kinds use native browser controls with their built-in keyboard handling.
  • The download anchor is keyboard-reachable with a visible focus ring on the toolbar.

API Reference

FileViewer

Prop Type Default Description
Src* string? — The URL of the file to preview. Required. Changing it cancels any in-flight text-based fetch (Markdown/Code/Json/Csv/Text) and starts resolving/loading the new file.
Kind FileKind FileKind.Auto Override detection with an explicit kind. Default Auto.
MimeType string? — Explicit MIME type from your backend (e.g. "application/pdf"). Wins over HEAD and extension detection but loses to an explicit Kind override.
FileName string? — Human-readable file name. Used for the toolbar label, the download attribute, and the aria-label. Falls back to the last URL path segment when not provided.
AutoHead bool false Issue a HEAD request to inspect Content-Type when neither Kind nor MimeType is set. Off by default because many CDNs and signed URLs reject HEAD with 405 / 403.
MaxBytes long 10L * 1024 * 1024 Cap on the number of bytes fetched for text-based kinds (Markdown, Code, JSON, CSV, Text). Default 10 MB. Excess content is truncated and a one-line notice is appended.
MaxCsvRows int 1000 Cap on the number of CSV rows rendered. Default 1000.
ShowToolbar bool true Show the top toolbar (file name + download). Default true.
ShowDownload bool true Show the download button in the toolbar. Default true.
ShowFileName bool true Show the file name in the toolbar. Default true.
HttpClient HttpClient? — Plug a custom HttpClient for the text fetches. Use this to attach a pre-configured Authorization handler instead of mutating the default client. Highest precedence — wins over HttpClientName, DI, and the factory.
HttpClientName string? — Name of the client to pull from a DI-registered IHttpClientFactory for the text fetches (Markdown/Code/JSON/CSV/Text). When the factory is registered (the common case once AddHttpClient() has been called) this is the recommended way to get a correctly-pooled client and avoid socket exhaustion. Ignored when an explicit HttpClient is supplied. Null/empty uses the factory's default client.
EnableOfficeOnlineViewer bool false Show an "Open in viewer" action on the Office-document fallback panel that opens the file in the Microsoft Office web viewer. Off by default because it sends the document URL to a Microsoft endpoint and only works for publicly reachable absolute http(s) URLs.
ConfigureRequest Func<HttpRequestMessage, Task>? — Hook to mutate the outgoing HttpRequestMessage before it's sent — typical use is adding an Authorization header for signed-but-not-presigned URLs.
CustomRenderers IReadOnlyDictionary<FileKind, RenderFragment<FileViewerRenderContext>>? — Per-kind renderer overrides. The fragment receives a FileViewerRenderContext with the resolved URL, kind, the fetched text (when applicable) and the display name. When provided for a given kind, the built-in renderer is bypassed for that kind.
LoadingTemplate RenderFragment? — Custom loading view. Default is a centered Spinner.
Class string? — Additional CSS classes merged onto the viewer's root container.
AdditionalAttributes Dictionary<string, object>? — Captures any unmatched attributes and applies them to the viewer's root container.

Events

OnKindDetected EventCallback<FileKind> Fires once detection settles, with the resolved kind.
OnLoaded EventCallback Fires when the file is fully rendered (text fetched, if any).
OnError EventCallback<string> Fires on any detection or fetch error, with a message.

@ref Methods

Method Description
RefreshAsync()Re-runs detection and (re)loads the current Src from scratch, cancelling any in-flight fetch. Use this to recover from an Error state without changing Src — the parameter-driven path early-outs when nothing changed, so an error against a stable URL is otherwise unrecoverable. Also wired to the Error panel's Retry button.