Blazor Is Still the Web: Building Beautiful Menus with HTML, CSS, and C#
When building a Blazor application, it is easy to reach for a heavy component library every single time you need something visual on the screen. Component libraries certainly have their place, but they are never a hard requirement for building clean, attractive user interfaces.
Blazor lets us use the exact same HTML and CSS that power the rest of the web. We can build robust layouts with CSS Grid, arrange content cleanly with Flexbox, animate navigation panels with CSS transitions, and rely on ordinary buttons, links, and images. Then, we wire up the interactions using C#.
To prove this out, I put together a small .NET 10 Blazor WebAssembly application featuring three distinct examples:
- A slide-out navigation menu.
- A custom multi-select control.
- A responsive restaurant menu complete with food photography.
None of these examples relies on a third-party UI component library. Their visual presentation comes purely from HTML and CSS, while their application behavior is driven entirely by Razor and C#.
What I Mean by 'Raw HTML'
In this context, 'raw HTML' simply means writing ordinary HTML elements directly inside a Razor component file.
<h1>Restaurant Menu</h1>
<div class="menu-item">
<img src="images/food/pasta.jpg" alt="Pesto pasta with tomatoes" />
<h3>Pesto Pasta</h3>
<p>Bow-tie pasta tossed with basil pesto and fresh greens.</p>
</div>
These are familiar, standard HTML elements. Razor allows us to mix that markup seamlessly with C# expressions, loops, and event handlers.
This approach is not about injecting raw HTML strings using MarkupString. We do not need MarkupString for any example here. Normal Razor expressions automatically encode text values; bypassing that protection creates security vulnerabilities and should always be avoided for untrusted content.
The takeaway is straightforward: using Blazor does not mean giving up semantic HTML or standard CSS.
The Sample Application
The sample is a standalone Blazor WebAssembly application targeting .NET 10. Here is a look at the project layout:
Menus.Client
Pages
Home.razor
RawHtmlMultiSelect.razor
RawHtmlMultiSelect.razor.css
ResturantMenu.razor
ResturantMenu.razor.css
wwwroot
css
app.css
images
food
pasta.jpg
salad.jpg
cake.jpg
ice-cream.jpg
index.html
The restaurant page is named ResturantMenu.razor, and its route is /resturant-menu. I am intentionally preserving that original spelling here so the code snippets match the application files.
Each page starts with an @page directive to define its URL route:
@page "/raw-html-multi-select"
The application router discovers these routed components automatically. An ordinary anchor tag takes us right to one:
<a href="raw-html-multi-select">Raw HTML Multi Select</a>
1. A Slide-Out Menu: C# Controls State, CSS Controls Motion
In Blazor, we can achieve the exact same visual result without writing custom JavaScript DOM-manipulation routines.
Representing Open State in C#
The home page component tracks whether the navigation panel is currently open using a simple boolean field:
@code {
private bool navOpen;
private void ToggleNav() => navOpen = !navOpen;
private void CloseNav() => navOpen = false;
}
Instead of manually calling document.body.classList.toggle(...), we use navOpen to dynamically determine which CSS classes to render.
<nav class="nav-panel @(navOpen ? "is-open" : string.Empty)"
aria-label="Main navigation"
aria-hidden="@(navOpen ? "false" : "true")">
<button type="button"
class="nav-close"
aria-label="Close navigation"
@onclick="ToggleNav">
Close
</button>
<div class="nav-links-container">
<a href="raw-html-multi-select"
class="nav-link"
tabindex="@(navOpen ? 0 : -1)"
@onclick="CloseNav">
Raw HTML Multi Select
</a>
</div>
</nav>
<main class="main-content @(navOpen ? "is-shifted" : string.Empty)">
<button type="button" class="open-nav" @onclick="ToggleNav">
@(navOpen ? "Close Navigation" : "Open Navigation")
</button>
</main>
The @onclick directive binds an HTML event directly to a C# method. When triggered, Blazor automatically schedules a re-render. There is zero need to manually manipulate the DOM or invoke StateHasChanged().
Notice the explicit "true" and "false" string expressions assigned to aria-hidden. ARIA attributes require text representations; they do not function like HTML boolean attributes such as disabled.
Letting CSS Handle the Motion
The core transition and layout rules from wwwroot/css/app.css look like this:
:root {
--nav-width: min(350px, calc(100vw - 48px));
}
.nav-panel {
position: fixed;
top: 0;
left: 0;
width: var(--nav-width);
height: 100vh;
height: 100dvh;
transform: translateX(calc(-1 * var(--nav-width)));
transition: transform .22s ease;
background: linear-gradient(155deg, var(--blue-900), var(--blue-800));
}
.nav-panel.is-open {
transform: translateX(0);
}
.main-content {
transform: translateX(0);
transition: transform .22s ease;
}
.main-content.is-shifted {
transform: translateX(var(--nav-width));
}
The navigation width is capped at 350 pixels, gracefully shrinking on narrower viewports. A single CSS custom property controls both the panel width and the translation distance of the main content area.
This represents a clean separation of concerns: C# determines whether the menu is open. HTML establishes the structure. CSS dictates what opening looks like.
The application also uses inline SVG for icons, rendering them directly in Razor without dragging in an external icon package.
For a robust production drawer component, I would also incorporate proper focus management: preventing hidden controls from receiving keyboard focus, shifting focus appropriately upon opening and closing, and supporting the Escape key to dismiss.
2. A Custom Multi-Select: Replacing DOM Manipulation with State
In Blazor, we can declare the markup once and let component state drive both the visible UI and underlying form elements seamlessly.
Defining Options and Selected Values
The component maintains a static language array and a HashSet<string> to track selections:
private static readonly (string Value, string Label)[] Languages =
[
("html", "HTML"),
("css", "CSS"),
("javascript", "JavaScript"),
("python", "Python"),
("sql", "SQL"),
("kotlin", "Kotlin")
];
private readonly HashSet<string> selectedValues = [];
private void ToggleSelection(string value)
{
if (!selectedValues.Add(value))
{
selectedValues.Remove(value);
}
}
HashSet<string>.Add() returns false if the item already exists in the set. That gives us a concise toggle operation in a single line of logic.
Rendering Ordinary HTML Buttons
Here is the visible control markup from RawHtmlMultiSelect.razor:
<p id="language-instructions">
Select one or more languages. Select a language again to remove it.
</p>
<div class="select"
role="group"
aria-label="Languages"
aria-describedby="language-instructions">
@foreach (var language in Languages)
{
<button type="button"
class="select__item @(selectedValues.Contains(language.Value) ? "select__item--selected" : string.Empty)"
aria-pressed="@(selectedValues.Contains(language.Value) ? "true" : "false")"
@onclick="() => ToggleSelection(language.Value)">
@language.Label
</button>
}
</div>
The original HTML/JS prototype used clickable <div> elements. I replaced them with proper <button> elements. That small architectural shift matters immensely: native buttons handle keyboard focus and activation out of the box via Enter or Space, while aria-pressed communicates toggle state to screen readers.
Synchronizing the Native Select
To preserve parity with the original example, the component retains a hidden native select element:
<select name="language"
class="custom-select"
multiple
hidden
aria-label="Languages">
@foreach (var language in Languages)
{
<option value="@language.Value"
selected="@selectedValues.Contains(language.Value)">
@language.Label
</option>
}
</select>
Both the custom button grid and the hidden native select read directly from the exact same C# state collection. We never have to search the DOM by index or manually tweak selected properties.
Styling with CSS Grid
The visual presentation is powered by clean, standard CSS:
.select {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
max-width: 300px;
gap: 1px;
}
.select__item {
min-height: 44px;
padding: 10px 6px;
border: 0;
border-radius: 3px;
color: var(--slate-700);
background: var(--slate-200);
font-family: 'Assistant', sans-serif;
font-size: 16px;
text-align: center;
cursor: pointer;
transition: background-color .1s ease;
}
.select__item--selected {
color: white;
background: var(--blue-600);
}
.select__item:focus-visible {
position: relative;
z-index: 1;
outline: 3px solid var(--blue-500);
outline-offset: 3px;
}
The selection state changes, the CSS class updates accordingly, and the browser repaints the UI. We have successfully shifted our mental model from imperatively hunting down and modifying DOM elements to declaratively rendering elements from state.
3. A Restaurant Menu: HTML Layout Meets C# Data
The restaurant menu page combines section headers, food photography, descriptions, and pricing. CSS handles responsive positioning, while C# provides the structured data source.
Modeling the Content
Each menu item is modeled as a lightweight C# record:
private sealed record MenuItem(
string Name,
decimal Price,
string Image,
string Alt,
string Description);
Entrees and desserts are organized into structured groups:
("Entrée",
[
new("Pesto Pasta", 16.90m, "pasta.jpg",
"Bow-tie pasta with pesto, cherry tomatoes, and greens",
"Bow-tie pasta tossed with basil pesto, cherry tomatoes, and fresh greens."),
new("Garden Salad", 12.90m, "salad.jpg",
"Colorful salad with avocado, chickpeas, and fresh vegetables",
"A colorful mix of leafy greens, avocado, chickpeas, and fresh vegetables with a creamy dressing.")
])
Rendering with Razor Loops
Here is the markup from ResturantMenu.razor:
<div class="menu">
@foreach (var group in MenuGroups)
{
<section aria-label="@group.Name">
<h2 class="menu-group-heading">@group.Name</h2>
<div class="menu-group">
@foreach (var item in group.Items)
{
<div class="menu-item">
<img class="menu-item-image"
src="@($"images/food/{item.Image}")"
alt="@item.Alt"
width="125"
height="125" />
<div class="menu-item-text">
<h3 class="menu-item-heading">
<span class="menu-item-name">@item.Name</span>
<span class="menu-item-price">@item.Price.ToString("C2", System.Globalization.CultureInfo.GetCultureInfo("en-US"))</span>
</h3>
<p class="menu-item-description">
@item.Description
</p>
</div>
</div>
}
</div>
</section>
}
</div>
Razor loops eliminate repetition, and interpolated C# expressions insert item details cleanly. Prices are stored as decimals and formatted explicitly as US currency using en-US culture rules for consistency.
Responsive Layout via CSS
The layout relies entirely on CSS grid and flexbox rules rather than procedural C# code:
.menu-group {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5em;
padding: 1.5em 0;
}
.menu-item {
display: flex;
min-width: 0;
}
.menu-item-image {
width: 80px;
height: 80px;
flex-shrink: 0;
object-fit: cover;
margin-right: 1.5em;
}
.menu-item-text {
flex-grow: 1;
min-width: 0;
}
.menu-item-heading {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: .5em 0;
margin: 0;
}
@media screen and (min-width: 992px) {
.menu {
font-size: 16px;
}
.menu-group {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.menu-item-image {
width: 125px;
height: 125px;
}
}
On mobile screens, each section displays a single column. At 992 pixels and above, it automatically expands to a clean two-column grid. Flexbox aligns images with text, and object-fit: cover crops photos into crisp square thumbnails.
There are no resize event listeners or C# viewport queries running in the background. The browser already knows how to handle responsive layouts natively.
One Theme, Three Different Interfaces
Despite serving different purposes across the application, all three pages share a cohesive blue-and-slate design palette managed through global CSS custom properties in wwwroot/css/app.css:
:root {
--blue-100: #dbeafe;
--blue-200: #bfdbfe;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-800: #1e40af;
--blue-900: #1e3a8a;
--slate-200: #e2e8f0;
--slate-700: #334155;
--slate-900: #0f172a;
}
Keeping Page Styles Local with CSS Isolation
Blazor supports companion stylesheets named directly after their components, such as RawHtmlMultiSelect.razor.css and ResturantMenu.razor.css.
At build time, Blazor automatically scopes selectors and injects matching attributes into the rendered HTML markup, ensuring styles do not bleed across components unexpectedly. Global theme variables stay in app.css, while component-specific rules reside in companion files.
Bringing an HTML Example into Blazor
When migrating an existing HTML/CSS prototype into Blazor, follow this practical workflow:
- Keep the useful HTML structure inside your .razor component. Do not duplicate document shells like <html>, <head>, or <body>—those live in index.html.
- Place component-specific styles into a companion .razor.css file.
- Replace DOM-oriented class toggles with C# booleans and conditional CSS classes.
- Use Razor loops to render repetitive data structures cleanly.
- Preserve semantic HTML by using proper anchor tags for navigation and buttons for interactive triggers.
- Test thoroughly in the browser across viewport sizes and keyboard navigation flows.
Do We Still Need Component Libraries?
Absolutely. Mature third-party component libraries are invaluable for complex requirements like enterprise data grids, advanced date pickers, or highly specialized widgets with heavy accessibility burdens. Building those from scratch requires significant engineering effort.
However, a component library should always be an intentional choice, never a mandatory prerequisite. For navigation drawers, responsive content grids, or custom toggle controls, plain HTML and CSS deliver exactly what you need with fewer dependencies and absolute control over the output.
The Takeaway
This sample application serves as a reminder that Blazor is not an abstraction meant to wall you off from the web platform. Instead, it is a powerful vehicle built on top of it.
HTML provides the structure. CSS provides the presentation. Razor and C# connect the interface cleanly to your application logic. If you already know how to craft great user interfaces with HTML and CSS, that knowledge remains 100% applicable and valuable in Blazor.




Comments
Post a Comment