Lumeo.FileViewerA11ydocumentDepends oncode-editorempty-stateiconpdf-viewerspinnerTested
Tier 3 · 30 testsView source ↗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 />
30 tests across 6 files. Auto-generated from the test suite.
Lumeo.FileViewer — install it alongside the core Lumeo
package. It transitively pulls Lumeo.PdfViewer
(for the PDF kind) and Lumeo.CodeEditor (for the Code / JSON
kinds), so a single install covers every supported preview.
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:
- Explicit
Kindparameter (anything other thanAutoshort-circuits) - Explicit
MimeTypeparameter (most reliable for blob URLs / signed URLs where the extension is hidden) - Optional
HEADrequestContent-Type— off by default (AutoHead="true"to enable) because many CDNs reject HEAD with 405 - URL extension — the last-resort guess from the path segment
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
HttpClientparameter orConfigureRequestdelegate - Safety caps —
MaxBytesfor text fetches,MaxCsvRowsfor tabular data - Pluggable per-kind overrides via
CustomRenderers
Quick start
<FileViewer Src="@DocumentUrl"
FileName="@Document.Name"
OnLoaded="HandleLoaded"
Class="h-[480px]" />
Resolution order
- Explicit
Kindparameter (anything other thanAutowins) - Explicit
MimeTypeparameter HEADrequestContent-Type(whenAutoHead="true")- 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.
| component | category | nuget_package | first_release |
|---|---|---|---|
| Accordion | Navigation | Lumeo | 1.0.0 |
| DataGrid | Data Display | Lumeo.DataGrid | 2.0.0 |
| FileViewer | Data Display | Lumeo.FileViewer | 3.2.5 |
| Map | Data Display | Lumeo.Maps | 3.2.0 |
| PdfViewer | Data Display | Lumeo.PdfViewer | 3.1.0 |
| QueryBuilder | Forms | Lumeo | 2.1.0 |
| RichTextEditor | Forms | Lumeo.Editor | 2.0.0 |
| Scheduler | Data Display | Lumeo.Scheduler | 2.0.0 |
| Toast | Feedback | Lumeo | 1.0.0 |
| TreeView | Data Display | Lumeo | 1.0.0 |
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
HttpClientparameter orConfigureRequestdelegate - Safety caps —
MaxBytesfor text fetches,MaxCsvRowsfor tabular data - Pluggable per-kind overrides via
CustomRenderers
Quick start
<FileViewer Src="@DocumentUrl"
FileName="@Document.Name"
OnLoaded="HandleLoaded"
Class="h-[480px]" />
Resolution order
- Explicit
Kindparameter (anything other thanAutowins) - Explicit
MimeTypeparameter HEADrequestContent-Type(whenAutoHead="true")- 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.
FileKind.Markdown| <!DOCTYPE html> | ||||||
|---|---|---|---|---|---|---|
| <html lang=en> | ||||||
| <head> | ||||||
| <meta charset=utf-8 /> | ||||||
| <meta name=viewport content=width=device-width, initial-scale=1.0 /> | ||||||
| <title>Lumeo — 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 | 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) | ||||||
| · | ||||||
| <a href=demos/enterprise class=text-foreground no-underline hover:underline>Meridian Ops</a> (demos/enterprise) | ||||||
| · | ||||||
| <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> |
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) directlyConfigureRequest="..."— mutate the outgoingHttpRequestMessageper call (typical forAuthorizationheaders)- Register
HttpClientin DI — picked up automatically (the standard Blazor WASM pattern)
Safety Limits
MaxBytes(default 10 MB) — refused upfront viaContent-Lengthwhen 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.mdnever reach the DOM. - SVG renders via
<img>(not inline) — embedded scripts in SVG can't execute. - In-flight fetches are cancelled via
CancellationTokenwhenSrcchanges.
Accessibility
- Body region wears
role="document"with anaria-labelderived from the file name or kind. - Loading + error states announce via the default
Spinner/EmptyStatecomponents (both come pre-wired witharia-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. |