loading...
Cover image for Building a sexy, mobile-ready navbar in any web framework
Hack4Impact

Building a sexy, mobile-ready navbar in any web framework

bholmesdev profile image Ben Holmes ・Updated on ・9 min read

I've been building a lot more static sites recently, and every one of them needs the same thing:

  • A nice and responsive navigation bar with logo on the left, links on the right πŸ’ͺ
  • For mobile screens, collapse those links on the right into a hamburger menu with a dropdown πŸ”
  • Hit all the marks for accessibility: semantic HTML, keyboard navigation, and more ♿️
  • Add some polished animations for that sleek, modern feel ✨

Oh, and implement it using whatever framework the team is using. This may sound daunting... but after bouncing between React, Svelte, and plain-ole JS, and I think I've found a solid solution you can take with you wherever you go.

Onwards!

First, what's the end goal?

Here's a screen-grab from my most recent project: redesigning the Hack4Impact nonprofit site.

Demoing responsive mobile nav-bar with screen resize

Ignore the cats. We needed some purrfect placeholders while we waited on content 😼

This has some fancy bells and whistles like that background blur effect, but it covers the general "formula" we're after!

Lay down some HTML

Let's define the general structure of our navbar first.

<nav>
    <a class="logo" href="/">
    <img src="dope-logo.svg" alt="Our professional logo (ideally an svg!)" />
  </a>
  <button class="mobile-dropdown-toggle" aria-hidden="true">
    <!-- Cool hamburger icon -->
  </button>
  <div class="dropdown-link-container">
    <a href="/about">About Us</a>
    <a href="/work">Our Work</a>
    ...
  </div>
</nav>
Enter fullscreen mode Exit fullscreen mode

A few things to note here:

  1. We're not using an unordered list (ul) for our links here. You might see this recommendations floating around the web, and it's certainly a valid one! However, this nuanced for / against piece from Chris Coyier really solidified things for me. In short: lists aren't required for a11y concerns (the problem is minimal at best), so we can ditch them if we have a fair reason to do so. In our case, we actually need to ditch the list so we can add our dropdown-link-container without writing invalid HTML. To understand what I mean, I clarified the issue to a kind commenter here!
  2. You'll notice our dropdown-link-container element, which wraps around all our links except the logo. This div won't do anything fancy for desktop users. But once we hit our mobile breakpoint, we'll hide these elements in a big dropdown triggered by our mobile-dropdown-toggle button.
  3. We're slapping an aria-hidden attribute on our dropdown toggle. For a simple nav like this, there's no reason for a screenreader to pick up on this button; it can always pick up on all our links even when they're "visually hidden", so there's no toggling going on πŸ€·β€β™€οΈ Still, if you really want to mimic the "toggle" effect for these users (which you should for super busy navbars), you can look into adding aria-expanded to your markup. This is getting a bit in the weeds for this article though, so you can use my easy-out for now.

For those following along at home, you should have something like this:

Now, some CSS

Before worrying about all that mobile functionality, let's spiff up the widescreen experience.

Our base styles

To start, we'll set up the alignment and width for our navbar.

nav {
  max-width: 1200px; /* should match the width of your website content */
  display: flex;
  align-items: center; /* center each of our links vertically */
  margin: auto; /* center all our content horizontally when we exceed that max-width */
}

.logo {
  margin-right: auto; /* push all our links to the right side, leaving the logo on the left */
}

.dropdown-link-container > a {
  margin-left: 20px; /* space out all our links */
}

.mobile-dropdown-toggle {
  display: none; /* hide our hamburger button until we're on mobile */
}
Enter fullscreen mode Exit fullscreen mode

The max-width property is an important piece here. Without it, our nav links will get pushed wayyyy to the right (and our logo wayyyy to the left) for larger screens. Here's a little before-and-after to show you what I mean.

Nav bar with some bad resizing

*Before: * Our nav elements stick to the edges of the screen. This doesn't match up with our page content very well, and makes navigation awkward on larger devices.

Nav bar with some good resizing

*After: * Everything is beautifully aligned, making our website a lot more "scan-able."

Of course, you can add padding, margins, and background colors to-taste πŸ‘¨β€πŸ³ But as long as you have a max-width and margin: auto for centering the nav on the page, you're 90% done already! Here's another pen to see it in action:

Adding the dropdown

Alright, now let's tackle our dropdown experience. First, we'll just focus on re-styling our links into a vertical column that takes up the height of the page:

@media (max-width: 768px) { /* arbitrary breakpoint, around the size of a tablet */
  .dropdown-link-container {
    /* first, make our dropdown cover the screen */
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: 100vh;
    /* fix nav height on mobile safari, where 100vh is a little off */
    height: -webkit-fill-available;

    /* then, arrange our links top to bottom */
    display: flex;
    flex-direction: column;
    /* center links vertically, push to the right horizontally.
       this means our links will line up with the rightward hamburger button */
    justify-content: center;
    align-items: flex-end;

    /* add margins and padding to taste */
    margin: 0;
    padding-left: 7vw;
    padding-right: 7vw;

    background: lightblue;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is pretty standard for the most part. Just a few things of note here:

First, we use position: fixed to align our dropdown to the top of our viewport. This is distinct from position: absolute, which would shift the nav's position depending on our scroll position 😬

Then, we use the -webkit-fill-available property to fix our nav height in mobile Safari. I'm sure you're thinking "what, how is 100vh not 100% of the user's screen size? What did Apple do this time?" Well, the problem comes from the iOS' disappearing URL bar. When you scroll, a bunch of UI elements slide out of the way to give you more screen real estate. That's great and all, but it means anything that used to take up 100% of the screen now needs to be resized! We have this problem on our Bits of Good nonprofit homepage:

Safari nav height problem

Notice the links aren't quite vertically centered until we swipe away all the Safari buttons. If you have a bunch of links, this could lead to cut-off text and images too!

In the end, all you need is the override height: -webkit-fill-available to specifically target this issue. Yes, feature flags like -webkit are usually frowned upon. But since this issue only pops up in mobile Safari (a webkit browser), there really isn't a problem with this approach in my opinion πŸ€·β€β™€οΈ Worst case, the browser falls back to 100vh, which is still a totally usable experience.

Finally, let's make sure our logo and dropdown buttons actually appear on top of our dropdown. Because of position:fixed, the dropdown will naturally hide everything underneath it until we add some z-indexing:

@media (max-width: 768px) {
  .logo, .mobile-dropdown-toggle {
    z-index: 1;
  }

  .mobile-dropdown-toggle {
    display: initial; /* override that display: none attribute from before */
  }

  .dropdown-link-container {
    ...
    z-index: 0; /* we're gonna avoid using -1 here, since it could position our navbar below other content on the page as well! */
  }
}
Enter fullscreen mode Exit fullscreen mode

Squoosh this CodePen to our breakpoint size to see these styles at work:

Let's animate that dropdown

Alright, we have most of our markup and styles finished up. Now, let's make that hamburger button do something!

We'll start with handling the menu button clicks. To show you how simple this setup is, I'll just use vanilla JS:

// get a ref to our navbar (assuming it has this id)
const navElement = document.getElementById("main-nav");

document.addEventListener("click", (event) => {
  if (event.target.classList.contains("mobile-dropdown-toggle")) {
    // when we click our button, toggle a CSS class!
    navElement.classList.toggle("dropdown-opened");
  }
});
Enter fullscreen mode Exit fullscreen mode

Now, we'll animate our dropdown into view whenever that dropdown-opened class gets applied:

/* inside the same media query from before */
@media (max-width: 768px) {
  ...
  .dropdown-link-container {
    ...
    /* our initial state */
    opacity: 0; /* fade out */
    transform: translateY(-100%); /* move out of view */
    transition: transform 0.2s, opacity 0.2s; /* transition these smoothly */
  }

  nav.dropdown-opened > .dropdown-link-container {
    opacity: 1; /* fade in */
    transform: translateY(0); /* move into view */
  }
}

Enter fullscreen mode Exit fullscreen mode

Nice! With just a few lines of CSS, we just defined a little fade + slide in effect whenever we click our dropdown. You can mess around with it here. Modify the transitions however you wish!

Adapting for big boy components

Alright, I know some of you want to slide this in your framework of choice at this point. Well, it shouldn't be too difficult! You can keep all the CSS the same, but here's a component snippet you can plop into React:

export const BigBoyNav = () => {
    const [mobileNavOpened, setMobileNavOpened] = useState(false);
    const toggleMobileNav = () => setMobileNavOpened(!mobileNavOpened);

  return (
    <nav className={mobileNavOpened ? 'dropdown-opened' : ''}>
      ...
      <button class="mobile-dropdown-toggle" onClick={toggleMobileNav} aria-hidden="true">
    </nav>
    )
}
Enter fullscreen mode Exit fullscreen mode

And one for Svelte:

<!-- ...might've included this to show how simple Svelte is :) -->
<script>
    let mobileNavOpened = false
  const toggleMobileNav = () => mobileNavOpened = !mobileNavOpened;
</script>

<nav className:mobileNavOpened="dropdown-opened">
    ...
  <button class="mobile-dropdown-toggle" on:click={toggleMobileNav} aria-hidden="true">
</nav>
Enter fullscreen mode Exit fullscreen mode

...You get the point. It's a toggle πŸ˜†

The little things

We have a pretty neat MVP at this point! I just left a couple accessiblity pieces for the end to get you to the finish line 🏁

Collapse that dropdown when you click a link

Note: You can skip this if you're using a vanilla solution like Jekyll, Hugo or some plain HTML. In those cases, the entire page will reload when you click a link, so there's no need to hide the dropdown!

If we're gonna cover the users entire screen, we should probably hide that dropdown again once they choose the link they want. We could just any click events in our dropdown like so:

document.addEventListener('click', event => {
  // if we clicked on something inside our dropdown...
  if (ourDropdownElement.contains(event.target)) {
    navElement.classList.remove('dropdown-opened')
  }
})
Enter fullscreen mode Exit fullscreen mode

...but this wouldn't be super accessible πŸ˜“. Sure, it handles mouse clicks, but how will it fare against keyboard navigation with the "tab" key? In that case, the user will tab to the link they want, hit "enter," and stay stuck in dropdown-opened without any feedback!

Luckily, there's a more "declarative" way to get around this problem. Instead of listening for user clicks, we can just listen for whenever the route changes! This way, we don't need to consider how the user navigates through our dropdown links; Just listen for the result.

Of course, this solution varies depending on your router of choice. Let's see how NextJS handles this problem:

export const BigBoyNav = () => {
  const router = useRouter(); // grab the current route with a React hook
  const activeRoute = router.pathname;

  ...
  // whenever "activeRoute" changes, hide our dropdown
  useEffect(() => {
    setMobileNavOpened(false);
  }, [activeRoute]);
}
Enter fullscreen mode Exit fullscreen mode

Vanilla React Router should handle this problem the same way. Regardless of your framework, just make sure you trigger your state change whenever the active route changes πŸ‘

Handle the "escape" key

For even better keyboard accessiblity, we should also toggle the dropdown whenever the "escape" key is pressed. This is bound to a very specific user interaction, so we're free to add an event listener for this one:

// vanilla JS
const escapeKeyListener = (event: KeyboardEvent) =>
    event.key === 'Escape' && navElement.classList.remove('dropdown-opened')

document.addEventListener('keypress', escapeKeyListener);
Enter fullscreen mode Exit fullscreen mode

...and for component frameworks, make sure you remove that event listener whenever the component is destroyed:

// React
useEffect(() => {
  const escapeKeyListener = (event: KeyboardEvent) =>
  event.key === 'Escape' && setMobileNavOpened(false);

  // add the listener "on mount"
  document.addEventListener('keypress', escapeKeyListener);
  // remove the listener "on destroy"
  return () => document.removeEventListener('keypress', escapeKeyListener);
}, []);
Enter fullscreen mode Exit fullscreen mode

See a fully-functional React example πŸš€

If you're curious how this could all fit together in a React app, our entire Hack4Impact website is accessible on CodeSandbox!

To check out the Nav component, head over here.

Thanks for reading! If this article was helpful...

I love writing about this sort of stuff πŸ‘¨β€πŸ’»
❀️ First, please check out the incredible work Hack4Impact is cooking up!
🐦 Follow my Twitter for random web dev tips and articles I find cool
πŸ“— Follow my blog for new posts every 2-3 weeks

And thanks to James Wang for nudging me to write this article. Might have sparked a whole mini-series on how we built our splash page

Discussion

pic
Editor guide
Collapse
konrud profile image
Konstantin Rouda

We're not using an unordered list (ul) for our links. You might see this recommendations floating around the web, but this article from Chris Coyier really solidified things for me.

Have you read the following post where Chris acknowledged that the previous post wasn't as right as he'd thought it would be?

At the end of this article, he's changing his previous belief.

css-tricks.com/wrapup-of-navigatio...

Collapse
bholmesdev profile image
Ben Holmes Author

Good catch! To be honest, I haven't ready this article myself. I wouldn't say this completely invalidates the approach I recommended here though. As a described here, adding unordered lists for this "type" of navigation would actually hurt accessibility. This is because we can't say that our logo / homepage link belongs to the list, which is a bit odd.

That said, this post raises some good points about role=navigation for example. I'll do a little digging on aria attributes to see how I can clarify my post 😁

Collapse
konrud profile image
Konstantin Rouda

As far as logo/homepage link is concerned, I'm not sure why can't you put your logo outside your <ul> but inside <nav> element? It would be exactly what you did in your markup but instead of using <div> you would use <ul>. As far as I can say, it would be valid accessible markup, as both <ul> menu and logo/homepage link reside inside the <nav>.

Thread Thread
bholmesdev profile image
Ben Holmes Author

Fair! I definitely agree it would be valid. However, given the reason screenreader users enjoy list groupings, it wouldn't make much sense to exclude the homepage link for that list.

Take the L shortcut for finding lists as an example. If a user were to find our new dropdown element in our nav, they may assume these are all the links they can navigate to, without clear direction on getting back to the homepage. At that point, there's not a lot of benefit to adding that list element for a11y. It also adds some complexity to our markup since we need to style both the li and the link inside the li (been burned by this before).

It's all bike-sheading at this point πŸ˜†Just thinking that uls wouldn't add a lot of value here, and it would make the dev process a little more complex πŸ€·β€β™€οΈ

Collapse
bholmesdev profile image
Ben Holmes Author

Update: I linked this article above instead of my previous recommendation! Kept the code samples as-is, but added a clearer explanation of the design decision as well.

Collapse
naygo profile image
Nayla Gomes

Hi! I loved your post. I didn't know that not using a cluttered list for links is good, thanks! I really liked your comments in the codes, especially in CSS, I always get confused about 'what does this line of CSS do?' haha, and how you point out important things you used and explain this is wonderful for people like me who are learning. Thanks again: D

Collapse
konrud profile image
Konstantin Rouda

Before you chose not to use a list in navigation. Please read the refuting article (css-tricks.com/wrapup-of-navigatio...) by Chris Coyier where he acknowledges that the previous article wasn't that obvious, to the least. It's very dangerous to say that something should or should not be used anymore only after reading one article.

Collapse
naygo profile image
Nayla Gomes

Okay, thanks!

Collapse
grodzickir profile image
Ryszard Grodzicki

Well, that's one of the most universally useful things I read in a while. Basically every dev in it's career, or in it's project, will stumble upon a task to create this "boilerplate" code for a webpage. And will probably use his outdated html/CSS knowledge or spend hours on reminding himself about new html tags, CSS animations and media queries. This articles saves a lot of time and dilemmas about technologies - simple, plain html/css stack and tips about applying it in popular frameworks.
Amazing! πŸ‘

Collapse
aptlyundecided profile image
Alex

I have been tinkering around with web since 2012 and I had no idea that nesting divs inside of list elements was bad.

Especially because material components in libraries like vuetify, angular-material, or material-ui for react typically have boat loads of elements kind of packed into their 'list-items'.

But now I understand that the material list components in the various libraries are actually just divs which have been morphed into a component 'class'.

Thanks for the post!

Collapse
bholmesdev profile image
Ben Holmes Author

Thanks so much! But yeah, it's crazy right? Most component libraries just bend <div>s to their will for everything. Buttons and toggle dropdowns come to mind for that.

But to clarify that "no divs in lists" comment: you can definitely use a div when it's nested inside another list item. So this is fine:

<ul>
  <li><div></div></li>
</ul>

However, it becomes a problem if you start using divs alongside other list items. So this would be bad:

<ul>
  <li><!--my awesome company logo--></li>
  <div class="dropdown-toggle">
    <li>...</li>
    <li>...</li>
  </div>
</ul>

This would pose a problem for our dropdown, since we want our logo to be another list item for supposed accessibility, but we can't wrap our other links in a dropdown div. Just scrapping the list entirely fixes that without a11y issues 😁

An alternative may be changing just the dropdown toggle to a ul, but this would exclude our homepage link from our "list" of navigation options. We're really splitting hairs at this point, but given that trade-off and added development overhead, I didn't think it's worth it πŸ€·β€β™€οΈ

Collapse
nemethricsi profile image
Richard

Very nice post thanks!!! πŸ’œ
One typo I guess: in the very first React example the onClick function should be toggleMobileNav instead of mobileNavOpened. Cheers 😊

Collapse
dance2die profile image
Sung M. Kim

Nice post!

I was working on a hamburger menu with ul/li and replaced'em with divs and spans as well as adding aria attributes~

Collapse
davidzcode profile image
David

A very instructive post!

Thanks for share πŸ˜„

Collapse
annietaylorchen profile image
Annie Taylor Chen

Awww love those cats! I really like how easy the animations are in svelte.

Collapse
bholmesdev profile image
Ben Holmes Author

Haha yeah, I could've removed them now that we have our production site up-and-running. Just thought it added some feline charm 😸

+1 for Svelte too! Considered showing off Svelte transitions to replace the manual CSS too, but that was a bit framework-specific. To whoever's reading this, go check out Svelte's quaint tutorial on transitions. They're incredible πŸš€