Feb 26 2023 02:14 PM
While I admittedly love Blazor I’ve always changed the out-of-the-box navigation menu that comes with it. It’s the first manoeuvre I pull when spinning up a new Blazor app, stripping out the purple gradient and getting it in, what I consider, a “blank slate state”. The other change I’ve wanted to make to the out-the-box look is one of those deluxe collapsible menus that leave just the icons showing. Anyone that’s used Azure DevOps will know what I’m talking about.
I’ve included a picture to show DevOps example of what I’d like to see in my Blazor app. It gives a load of extra screen real estate which is always a priority for me in business applications particularly with complex or intensive workflows. Plus it gives the user the option to remove the text prompts once they are familiar with the system which is supported with carefully selected icon choices.
As with most tasks that I assume will be an obvious solution I hit my search engine of choice and looked to avoid reinventing the wheel. However I found no source of pre-written changes to achieve this and was directed to fairly expensive third party controls to solve this one for me, which, being tight fisted, pushed me to do it for myself.
Here I hope you save you the trouble of paying a pretty penny or having to wrestle the CSS into submission and provide a guide for producing a nice collapsible icon navigation menu by altering the existing out of the box menu in Blazor.
In the following example I have left all the standard styling as is with the menu and just done the changes required to make the collapsible menu. The three files that require changes are MainLayout.razor, NavMenu.razor and NavMenu.razor.css. The code changes are shown below:
Firstly the NavMenu.razor requires a bool value (IconMenuActive) to indicate whether the icon menu is showing or not, then wrap the text of the each NavItem in an if statement dependent on this bool. Then a method for toggling this bool and EventCalBack to send a bool to the MainLayout.razor for shrinking the width of the sidebar. Lastly there needs to be the control for switching menu views (I used the standard io icon arrows).
NavMenu.razor
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<span class="oi oi-monitor" style="color:white;" aria-hidden="true"></span>
@if (!@IconMenuActive)
{
<a class="navbar-brand" href="">The Menu Title Here</a>
}
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span>
@if (!@IconMenuActive)
{
<label>Home</label>
}
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span>
@if (!@IconMenuActive)
{
<label>Counter</label>
}
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span>
@if (!@IconMenuActive)
{
<label>Fetch data</label>
}
</NavLink>
</div>
</nav>
</div>
<div class="bottom-row">
<div class="icon-menu-arrow">
@if (!@IconMenuActive)
{
<span class="oi oi-arrow-left" style="color: white;" @onclick="ToggleIconMenu"></span>
}
else
{
<span class="oi oi-arrow-right" style="color: white;" @onclick="ToggleIconMenu"></span>
}
</div>
</div>
@code {
//bool to send to MainLayout for shrinking sidebar and showing/hide menu text
private bool IconMenuActive { get; set; } = false;
//EventCallback for sending bool to MainLayout
[Parameter]
public EventCallback<bool> ShowIconMenu { get; set; }
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
//Method to toggle IconMenuActive bool and send bool via EventCallback
private async Task ToggleIconMenu()
{
IconMenuActive = !IconMenuActive;
await ShowIconMenu.InvokeAsync(IconMenuActive);
}
}
Next I add in a bit of CSS in to NavMenu.razor.css to put the arrow for toggling the menu at the bottom of the sidebar and a media query to make sure it doesn't show up in mobile view. The CSS classes added are .bottom-row and .icon-menu-arrow.
NavMenu.razor.css
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.bottom-row {
position: absolute;
bottom: 0;
padding-bottom: 10px;
text-align: right;
width: 100%;
padding-right: 28px;
}
.icon-menu-arrow {
text-align: right;
}
.navbar-brand {
font-size: 1.1rem;
}
.oi {
width: 2rem;
font-size: 1.1rem;
vertical-align: text-top;
top: -2px;
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.25);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
}
@media (max-width: 640px) {
.bottom-row {
display: block;
}
}
Finally I add in the handler for the EventCallback to MainLayout.razor and a method to alter the width of the sidebar.
MainLayout.razor
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar" style="@IconMenuCssClass">
<NavMenu ShowIconMenu="ToggleIconMenu"/>
</div>
<main>
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
@code{
private bool _iconMenuActive { get; set; }
private string? IconMenuCssClass => _iconMenuActive ? "width: 80px;" : null;
protected void ToggleIconMenu(bool iconMenuActive)
{
_iconMenuActive = iconMenuActive;
}
}
The final product of these little changes are shown in the pictures below:
I'd love to hear if anyone has tackled this in a different way to me and if they've got any ideas on making it cleaner.
Have yourselves a wonderful day,
Gav
Apr 27 2023 12:28 PM - edited Apr 27 2023 12:29 PM
This is AWESOME! I don't know why it doesn't behave like this out of the box. It's the way the Azure DevOps menu works.
Have you found a way to make the menu expand to full width with labels again when the screen is shrunk down to mobile size -- e.g. < 640px?
May 21 2023 07:58 PM
Jun 30 2023 08:14 AM
@Gavin-Williams, great article for sure and agree with the others that this functionality/style should be included in the base template in VS. But, I found an issue with the menu. If you click the arrow to collapse the menu and then restore your window to a size that would be of a phone, the behavior isn't as expected and it's like both menus are attempting to be visible:
Nov 29 2023 11:43 AM
Dec 18 2023 11:40 AM
@Checho1965 I just started a .NET project and added this code. Noticed the same issue. Going to work on debugging it however before I do, has anyone made progress on it?
Dec 19 2023 06:06 PM
I have encountered this problem also when using '@onclick'.
in .Net 8 I think this to do with render modes.
I added the following to the top of my razor page in my Blazor Web app and the onclick worked:
@rendermode InteractiveServer
In the solution you get 2 projects: the Web and the Client - the above goes in the Web project.
Feb 03 2024 01:48 PM
If it helps anyone else...With Blazor Web App on NET8, you need to add the open-iconic css and add the rendermode. After making these additions, the code supplied by @Gavin-Williams worked great. Thank you @Gavin-Williams for putting together.
In app.css, at the top of the page add:
@import url('https://cdnjs.cloudflare.com/ajax/libs/open-iconic/1.1.1/font/css/open-iconic-bootstrap.min.css');
In app.razor make sure to add the RenderMode in both the HeadOutlet and Routes (why not?):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="BlazorApp1.styles.css" />
<link rel="stylesheet" href=""
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
Apr 03 2024 09:31 AM
@jassen777 Thank you so much for this! I was tearing my hair out trying to figure out why none of my ideas for collapsing menus worked. I also added the option to have sub menu items with those icons available in the Menu Icon section. Here in my code.
@rendermode InteractiveServer
<div class="navbar-wrapper">
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a href="/dashboard"><img src="images/NewLogo-transparent.png" alt="Brand Logo" width="30" height="30"></a>
@if (!@IconMenuActive)
{
<a class="navbar-brand">Brand option</a>
}
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3" @onclick="ToggleSubMenu">
<NavLink class="nav-link flex-container" Match="NavLinkMatch.All">
<span class="oi oi-dollar" aria-hidden="true" title="Home"></span>
@if (!@IconMenuActive)
{
<label>Accounting</label>
<span class="oi chevron-icon @(showSubMenu ? "oi-chevron-top" : "oi-chevron-bottom") ps-3" style="font-size: 1rem;" aria-hidden="true"></span>
}
</NavLink>
<div class="sub-menu" style="display: @(showSubMenu ? "block" : "none")">
<NavLink class="nav-link" href="subitem1">
<span class="oi oi-folder" aria-hidden="true" title="Sub Item 1"></span>
@if (!@IconMenuActive)
{
<label>Home</label>
}
</NavLink>
<NavLink class="nav-link" href="subitem2">
<span class="oi oi-document" aria-hidden="true" title="Sub Item 2"></span>
@if (!@IconMenuActive)
{
<label>Sub Menu Item 1</label>
}
</NavLink>
</div>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="oi oi-calculator" aria-hidden="true"></span>
@if (!@IconMenuActive)
{
<label>Sub Menu Item 2</label>
}
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span>
@if (!@IconMenuActive)
{
<label>Fetch data</label>
}
</NavLink>
</div>
</nav>
</div>
<div class="bottom-row">
<div class="icon-menu-arrow">
@if (!@IconMenuActive)
{
<span class="oi oi-arrow-left" style="color: black;" @onclick="ToggleIconMenu"></span>
}
else
{
<span class="oi oi-arrow-right" style="color: black;" @onclick="ToggleIconMenu"></span>
}
</div>
</div>
</div>
@code {
private bool showSubMenu = false;
private void ToggleSubMenu()
{
showSubMenu = !showSubMenu;
}
//bool to send to MainLayout for shrinking sidebar and showing/hide menu text
private bool IconMenuActive { get; set; } = false;
//EventCallback for sending bool to MainLayout
[Parameter]
public EventCallback<bool> ShowIconMenu { get; set; }
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
//Method to toggle IconMenuActive bool and send bool via EventCallback
private async Task ToggleIconMenu()
{
IconMenuActive = !IconMenuActive;
await ShowIconMenu.InvokeAsync(IconMenuActive);
}
}
I'm still working on the styling but here's that as well.
.navbar-wrapper {
border-right: 1px solid #545454;
min-height: 100vh;
overflow: hidden;
}
.a:hover,a:active{
color: black;
}
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.top-row {
height: 3.5rem;
background-color: white;
border-bottom: 1px solid #d6d5d5;
}
.top-row ::deep a.active, .top-row ::deep a:hover {
color: black;
background-color: transparent;
}
.bottom-row {
position: absolute;
bottom: 3rem;
padding-bottom: 10px;
text-align: right;
width: 100%;
padding-right: 28px;
}
.icon-menu-arrow {
text-align: right;
}
.navbar-brand {
font-size: 1.1rem;
color: black;
}
.oi {
width: 2rem;
font-size: 1.1rem;
vertical-align: text-top;
top: -2px;
transition: ease-in-out 0.3s;
transform-origin: center;
}
.chevron-icon{
transition: transform 0.3s ease-in-out;
}
oi-chevron-top{
transform: rotate(180deg);
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #000000;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: #545454;
color: white;
}
.nav-item ::deep a:hover {
background-color: #545454;
color: white;
}
.sub-menu {
display: none;
flex-direction: column;
padding-left: 0.5rem;
}
.nav-item:hover .sub-menu,
.sub-menu:hover {
display: flex;
}
.sub-menu .nav-link {
font-size: 0.8rem;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
transition: width 1s ease;
min-width: 300px;
}
}
@media (max-width: 640px) {
.bottom-row {
display: block;
}
}
Jun 18 2024 12:44 PM
@MMaybe990-The submenus display (I have 3) but when I click a Menu item with sub-items, all of the sub-item are displayed. Any way to get around this?
Jul 03 2024 10:17 AM - edited Jul 03 2024 10:27 AM
@Gavin-Williams, thanks for sharing your solution. I encountered similar issues when implementing it with .NET 8. To assist others, I've created a GitHub repository called CollapsibleNavMenu that incorporates these changes. This repo also includes a submenu system designed to mimic the behaviour of Azure DevOps, building on @MMaybe990's implementation.
Additionally, I added some JavaScript to address the issue identified by @clockwiseq where changing the page width after collapsing the Nav Menu caused problems. The submenus are now uncoupled and can be independently expanded, which might interest @SCAppDev.
I hope this helps others in the future!
Jul 27 2024 04:28 AM
Thank you @H3ALY. Your solution works great but it messes up the login process. The login page blinks endlessly.
Jul 27 2024 04:33 AM
@PeterWak I'll take a look at throwing in the identity stuff and try reproduce myself this evening if I can.
Jul 28 2024 03:01 PM
The issue seems to be more with Identity, the default .NET8 WebApp configuration for a interactive blazor server will set the render mode in the HeadOutlet as:
<HeadOutlet @rendermode="InteractiveServer" />
with the following route:
<Routes @rendermode="InteractiveServer" />
The issue you're seeing is because Identity is expecting the HttpContext to not be null.
AccountLayout.Razor
@if (HttpContext is null)
{
<p>Loading...</p>
}
else
{
@Body
}
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
protected override void OnParametersSet()
{
if (HttpContext is null)
{
// If this code runs, we're currently rendering in interactive mode, so there is no HttpContext.
// The identity pages need to set cookies, so they require an HttpContext. To achieve this we
// must transition back from interactive mode to a server-rendered page.
NavigationManager.Refresh(forceReload: true);
}
}
}
This is raised here -Update Identity Components in Blazor project template A work around was provided in this issue - Make RenderTreeBuilder.AddComponentRenderMode's renderMode parameter nullable
You can update App.Razor with:
<HeadOutlet @rendermode="@RenderModeForPage" />
<Routes @rendermode="@RenderModeForPage" />
also including the following code block:
@code{
[CascadingParameter] HttpContext HttpContext { get; set; } = default!;
IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/Account")
? null
: RenderMode.InteractiveServer;
}
Which I've tested and seems to work, however the tests aren't extensive to say the least.
Jul 30 2024 05:33 AM