Convert the standard Blazor navigation menu to a collapsible icon menu

Brass Contributor

 

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.

DevOpsMenu.png

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:

 

CollapsingMenu.png

 

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

8 Replies

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?

This is amazing! Thank you so much for sharing :rocket:

@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:

KeithCulpepper_0-1688138083069.png

 

This code does not work with .Net 8. Can you share the project?

@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?

@Checho1965 

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.

 

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>

@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;
    }
}