Skip to content

Instantly share code, notes, and snippets.

@ryangittings
Last active July 11, 2022 14:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ryangittings/db82c7b859b3b99409f971d493faaee8 to your computer and use it in GitHub Desktop.
Save ryangittings/db82c7b859b3b99409f971d493faaee8 to your computer and use it in GitHub Desktop.
Burger Menu (Progressive, Responsive, Progressively Enhanced & Fluid)
burger-menu nav {
display: none;
}
.no-js burger-menu nav {
display: block;
}
.burger-menu nav {
display: block;
}
.burger-menu__trigger {
display: none;
}
.burger-menu__bar,
.burger-menu__bar::before,
.burger-menu__bar::after {
display: block;
width: 24px;
height: 2px;
background: currentColor;
border: 1px solid currentColor;
position: absolute;
border-radius: 2px;
left: 50%;
margin-left: -12px;
transition: transform 350ms ease-in-out;
}
.burger-menu__bar {
top: 50%;
transform: translateY(-50%);
}
.burger-menu__bar::before,
.burger-menu__bar::after {
content: '';
}
.burger-menu__bar::before {
top: -8px;
}
.burger-menu__bar::after {
bottom: -8px;
}
.burger-menu[enabled='true'] .burger-menu__trigger {
display: block;
width: 2rem;
height: 2rem; /* Nice big tap target */
position: relative;
z-index: 1;
background: transparent;
border: none;
cursor: pointer;
color: currentColor;
}
.burger-menu[enabled='true'] .burger-menu__panel {
position: absolute;
top: 0;
left: 0;
padding: calc(4rem + var(--size-400)) var(--size-400) var(--size-400);
width: 100%;
height: max-content;
visibility: hidden;
opacity: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
border-bottom: 1px solid var(--color-gray-800);
}
.burger-menu[enabled='true'] ul.nav {
display: flex;
width: 100%;
flex-direction: column;
align-items: stretch;
}
.burger-menu[enabled='true'][status='open'] .burger-menu__panel {
visibility: visible;
opacity: 1;
transition: opacity 400ms ease;
}
.burger-menu[enabled='true'][status='closed'] .burger-menu__panel > * {
opacity: 0;
transform: translateY(5rem);
}
.burger-menu[enabled='true'][status='open'] .burger-menu__panel > * {
transform: translateY(0);
opacity: 1;
transition: transform 500ms cubic-bezier(0.17, 0.67, 0, 0.87) 700ms, opacity 500ms ease 800ms;
}
.burger-menu[enabled='true'][status='open'] .burger-menu__bar::before {
top: 0;
transform: rotate(45deg);
}
.burger-menu[enabled='true'][status='open'] .burger-menu__bar::after {
top: 0;
transform: rotate(-45deg);
}
.burger-menu[enabled='true'][status='open'] .burger-menu__bar {
background: transparent;
border-color: transparent;
transform: rotate(180deg);
}
import getFocusableElements from './get-focusable-elements.js';
class BurgerMenu extends HTMLElement {
constructor() {
super();
const self = this;
this.state = new Proxy(
{
status: 'open',
enabled: false,
},
{
set(state, key, value) {
const oldValue = state[key];
state[key] = value;
if (oldValue !== value) {
self.processStateChange();
}
return state;
},
}
);
}
get maxWidth() {
return parseInt(this.getAttribute('max-width') || 9999, 10);
}
connectedCallback() {
this.initialMarkup = this.innerHTML;
this.render();
const observer = new ResizeObserver((observedItems) => {
const { contentRect } = observedItems[0];
this.state.enabled = window.innerWidth < this.maxWidth;
});
// We want to watch the parent like a hawk
observer.observe(this.parentNode);
}
render() {
this.innerHTML = `
<div class="burger-menu" data-element="burger-root">
<button class="burger-menu__trigger" data-element="burger-menu-trigger" type="button" aria-label="Open menu">
<span class="burger-menu__bar" aria-hidden="true"></span>
</button>
<div class="burger-menu__panel" data-element="burger-menu-panel">
${this.initialMarkup}
</div>
</div>
`;
this.postRender();
}
postRender() {
this.trigger = this.querySelector('[data-element="burger-menu-trigger"]');
this.panel = this.querySelector('[data-element="burger-menu-panel"]');
this.root = this.querySelector('[data-element="burger-root"]');
this.focusableElements = getFocusableElements(this);
if (this.trigger && this.panel) {
this.toggle();
this.trigger.addEventListener('click', (evt) => {
evt.preventDefault();
this.toggle();
});
document.addEventListener('focusin', () => {
if (!this.contains(document.activeElement)) {
this.toggle('closed');
}
});
return;
}
this.innerHTML = this.initialMarkup;
}
toggle(forcedStatus) {
if (forcedStatus) {
if (this.state.status === forcedStatus) {
return;
}
this.state.status = forcedStatus;
} else {
this.state.status = this.state.status === 'closed' ? 'open' : 'closed';
}
}
processStateChange() {
this.root.setAttribute('status', this.state.status);
this.root.setAttribute('enabled', this.state.enabled ? 'true' : 'false');
this.manageFocus();
switch (this.state.status) {
case 'closed':
this.trigger.setAttribute('aria-expanded', 'false');
this.trigger.setAttribute('aria-label', 'Open menu');
break;
case 'open':
case 'initial':
this.trigger.setAttribute('aria-expanded', 'true');
this.trigger.setAttribute('aria-label', 'Close menu');
break;
}
}
manageFocus() {
if (!this.state.enabled) {
this.focusableElements.forEach((element) => element.removeAttribute('tabindex'));
return;
}
switch (this.state.status) {
case 'open':
this.focusableElements.forEach((element) => element.removeAttribute('tabindex'));
break;
case 'closed':
[...this.focusableElements].filter((element) => element.getAttribute('data-element') !== 'burger-menu-trigger').forEach((element) => element.setAttribute('tabindex', '-1'));
break;
}
}
}
if ('customElements' in window) {
customElements.define('burger-menu', BurgerMenu);
}
export default BurgerMenu;
/**
* Returns back a NodeList of focusable elements
* that exist within the passed parnt HTMLElement
*
* @param {HTMLElement} parent HTML element
* @returns {NodeList} The focusable elements that we can find
*/
export default (parent) => {
if (!parent) {
console.warn('You need to pass a parent HTMLElement');
return [];
}
return parent.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)');
};
<burger-menu max-width="600">
<nav aria-label="primary">
<ul class="nav" role="list">
<li>
<a href="#">Home</a>
</li>
<li>
<a href="#">About</a>
</li>
<li>
<a href="#">Our Work</a>
</li>
<li>
<a href="#">Contact Us</a>
</li>
<li>
<a href="#">Your account</a>
</li>
</ul>
</nav>
</burger-menu>
<script src="/js/burger-menu.js" type="module"></script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment