Уроки

Бургер-меню на React, используя хуки и styled-components

Все знают кнопку-гамбургер, правильно? Знаменитые три полоски, позволяющие спрятать меню за экраном. Оно особенно востребовано на мобильных устройствах, где дорог каждый пиксель пустого пространства.

Нравится ли вам или нет, но гамбургер меню используется и будет использоваться ещё какое-то время. Проблема в том, как его реализовать на React’е? Конечно, всё выглядит просто и понятно, но есть множество тонкостей. Например, стоит ли добавлять label? С какой стороны лучше показывать – слева или справа? Лучше ли использовать SVG, шрифт, символ unicode или чистый CSS?

Я хотел добавить гамбургер в свое приложение, но не смог найти ничего подходящего. Большинство меню являются либо частью какого-либо фреймворка, такого как Material Design for Bootstrap или же просто слишком сложные для маленького приложения (пример react-burger-menu). Когда я просто хотел кнопку и выезжающую панель.

Поэтому сегодня мы с вами сделаем собственный гамбургер с сайдбаром. Вы готовы? Тогда поехали!

Готовое демо

Готовый проект на github.com

Начало

Создадим новый проект, используя create-react-app.

create-react-app hamburger-demo
cd hamburger-demo
npm install --save styled-components prop-types

Создаем контекст

Итак, первая задача, с которой мы сталкиваемся – как передавать сообщение о событии onClick другому компоненту? Конечно, мы можем в родительском компоненте хранить статус isMenuOpen и передавать callback дочернему элементу. Но по-моему гораздо проще и логичнее использовать контекст для этих целей.

Контект будет передавать два значения:

  • Булевую переменную isMenuOpen, для информирования компонентов о статусе.
  • Функцию toggleMenuMode для переключения статуса менюОткрыто/менюЗакрыто.

Создадим файл src/context/navState.js со следующим содержанием:

import React, { createContext, useState } from 'react';
import PropTypes from 'prop-types';

export const MenuContext = createContext({
  isMenuOpen: true,
  toggleMenu: () => {},
});

const NavState = ({ children }) => {
  const [isMenuOpen, toggleMenu] = useState(false);

  function toggleMenuMode() {
    toggleMenu(!isMenuOpen);
  }

  return (
    <MenuContext.Provider value={{ isMenuOpen, toggleMenuMode }}>{children}</MenuContext.Provider>
  );
};

NavState.propTypes = {
  children: PropTypes.node.isRequired,
};

export default NavState;

Кнопка-гамбургер

Теперь, после того как мы создали контекст, можно приступать и к самой кнопке. Иконка гарбургера состоит из 3-х span элементов. При наведении, мы просто меняем длину верхней и нижней полоски. Когда пользователь нажимает на кнопку, мы вызываем функцию toggleMenuMode(), которое переключит состояние isMenuOpen.

Если isMenuOpen истинно, тогда кнопке присваивается класс active. Активное состояние превращает кнопку в крестик, что достигается двумя трансформациями – поворотом на 45 градусов и перемещением на несколько пикселей. В это же время, средняя полоска скрывается.

Создадим новый файл src/components/HamburgerButton.js и добавим следующий код:

import React, { useContext } from 'react';
import styled from 'styled-components';
import { MenuContext } from '../context/navState';

const MenuButton = styled.button`
  display: block;
  transform-origin: 16px 11px;
  float: left;
  margin-right: 29px;
  outline: 0;
  border: 0;
  padding: 12px;
  background: none;

  span {
    transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  }

  :focus {
    border: medium none rgb(111, 255, 176);
    box-shadow: rgb(111, 255, 176) 0 0 2px 2px;
    outline: 0;
  }

  :hover {
    span:nth-of-type(1) {
      width: 33px;
    }

    span:nth-of-type(2) {
      width: 40px;
    }

    span:nth-of-type(3) {
      width: 30px;
    }
  }

  &.active {
    span:nth-of-type(1) {
      transform: rotate(45deg) translate(10px, 10px);
      width: 40px;
    }

    span:nth-of-type(2) {
      opacity: 0;
      pointer-events: none;
    }

    span:nth-of-type(3) {
      transform: rotate(-45deg) translate(7px, -7px);
      width: 40px;
    }
  }
`;

const Bar = styled.span`
  display: block;
  width: 40px;
  height: 5px;
  margin-bottom: 7px;
  background-color: #fff;
`;

const HamburgerButton = () => {
  const { isMenuOpen, toggleMenuMode } = useContext(MenuContext);

  const clickHandler = () => {
    toggleMenuMode();
  };

  return (
    <MenuButton
      className={isMenuOpen ? 'active' : ''}
      aria-label="Открыть главное меню"
      onClick={clickHandler}
    >
      <Bar />
      <Bar />
      <Bar />
    </MenuButton>
  );
};

export default HamburgerButton;

Выезжающее меню

Кнопка-гамбургер у нас уже есть, осталось дело за малым – добавить само меню. Оно состоит из двух компонентов – меню и ссылок. Меню скрывается с помощью нехитрого трюка с transform: translateX(-100%);. Таким образом по умолчанию меню скрыто за левым краем экрана, но выезжает как только мы меняем значение translateX на 0.

У ссылок же в фон я поставил стрелку, её можно скачать из репозитория. При наведении курсора изменяется позиция фона, таким образом создается эффект “движения” стрелки.

Создадим файл src/components/SideMenu.js со следующим содержанием:

import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import styled, { css } from 'styled-components';
import { MenuContext } from '../context/navState';
import arrow from '../arrow.svg';

const Menu = styled.nav`
  position: absolute;
  top: 0px;
  left: 0px;
  bottom: 0px;
  z-index: 293;
  display: block;
  width: 400px;
  max-width: 100%;
  margin-top: 0px;
  padding-top: 100px;
  padding-right: 0px;
  align-items: stretch;
  background-color: #001698;
  transform: translateX(-100%);
  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);

  ${props =>
    props.open &&
    css`
      transform: translateX(0);
    `}
`;

export const MenuLink = styled.a`
  position: relative;
  display: block;
  text-align: left;
  max-width: 100%;
  padding-top: 25px;
  padding-bottom: 25px;
  padding-left: 16%;
  background-image: url(${arrow});
  background-position: 88% 50%;
  background-size: 36px;
  background-repeat: no-repeat;
  transition: background-position 300ms cubic-bezier(0.455, 0.03, 0.515, 0.955);
  text-decoration: none;
  color: #fff;
  font-size: 32px;
  line-height: 120%;
  font-weight: 500;

  :hover {
    background-position: 90% 50%;
  }
`;

export const SideMenu = ({ children }) => {
  const { isMenuOpen } = useContext(MenuContext);

  return <Menu open={isMenuOpen}>{children}</Menu>;
};

SideMenu.propTypes = {
  children: PropTypes.node,
};

SideMenu.defaultProps = {
  children: (
    <>
      <MenuLink href="/">Главная</MenuLink>
      <MenuLink href="/articles">Статьи</MenuLink>
      <MenuLink href="/about">О сайте</MenuLink>
      <MenuLink href="/contact">Контакт</MenuLink>
    </>
  ),
};

Навбар

Остался последний компонент, навбар. О функции useOnClickOutside будет идти речь ниже, а пока всё оставим как есть.

src/components/MainMenu.js

import React, { useRef, useContext } from 'react';
import styled from 'styled-components';
import useOnClickOutside from '../hooks/onClickOutside';
import { MenuContext } from '../context/navState';
import HamburgerButton from './HamburgerButton';
import { SideMenu } from './SideMenu';

const Navbar = styled.div`
  display: flex;
  position: fixed;
  left: 0;
  right: 0;
  box-sizing: border-box;
  outline: currentcolor none medium;
  max-width: 100%;
  margin: 0px;
  align-items: center;
  background: #082bff none repeat scroll 0% 0%;
  color: rgb(248, 248, 248);
  min-width: 0px;
  min-height: 0px;
  flex-direction: row;
  justify-content: flex-start;
  padding: 6px 60px;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 16px;
  z-index: 500;
`;

const MainMenu = () => {
  const node = useRef();
  const { isMenuOpen, toggleMenuMode } = useContext(MenuContext);
  useOnClickOutside(node, () => {
    // Only if menu is open
    if (isMenuOpen) {
      toggleMenuMode();
    }
  });

  return (
    <header ref={node}>
      <Navbar>
        <HamburgerButton />
        <h1>Website</h1>
      </Navbar>
      <SideMenu />
    </header>
  );
};

export default MainMenu;

Собираем всё воедино

Все компоненты готовы, осталось лишь правильно это всё вызвать. В файле src/App.js должно быть:

import React from 'react';
import NavState from './context/navState';
import MainMenu from './components/MainMenu';

function App() {
  return (
    <NavState>
      <MainMenu />
    </NavState>
  );
}

export default App;

И последняя вспомогательная функция, когда пользователь кликает мышью вне навбара/меню, меню должно автоматически скрываться. Как это сделать? В React’е есть хук useRef(), который позволяет обращаться напрямую к DOM. Таким образом, при клике можно проверять, попадает ли клик в заданный реф или не попадает.

Код return () => {...} сработает при демонтаже компонента, во избежании утечек памяти, слушатель удаляется. В конце в квадратных скобочках указаны зависимости [ref, handler], при обновлении рефа обновится так же и слушатель.

src/hooks/onClickOutside.js

import { useEffect } from 'react';

const useOnClickOutside = (ref, handler) => {
  useEffect(() => {
    const listener = event => {
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      handler(event);
    };
    document.addEventListener('mousedown', listener);
    return () => {
      document.removeEventListener('mousedown', listener);
    };
  }, [ref, handler]);
};

export default useOnClickOutside;

Всё, наше меню готово. Ура!

Сергей Монин

Сергей Монин

Я фриланствующий веб-разработчик, живу в Саратове. Провожу дни выполняя заказы на дому. Хорошо разбираюсь в PHP и JavaScript. Интересуюсь аналитикой, языком программирования R, web accessibility и другими вещами.