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


This first example started as an HTML navigation panel powered by a JavaScript function that toggled a class on the document body. The goal was simple: hide the panel off-screen to the left, then slide it smoothly into view when opened.

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


The second example originally relied on a native HTML <select multiple> paired with custom JavaScript that dynamically created elements, registered click handlers, managed selection classes, and synchronized native options.

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 third page highlights another crucial reality: not every polished UI interface requires complex interactivity. Sometimes you just need clean, beautiful information delivery.

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.


[source code]

Comments

Popular posts from this blog

Customizing PWA Manifest and Icons for a Polished User Experience 🚀

Offline-First Strategy with Blazor PWAs: A Complete Guide 🚀

Yes, Blazor Server can scale!