Volto Light Theme: Fat Menu - How to make some navigation items go to the item on first click

For the Volto Light Theme, when the fat menu is enabled, standalone pages should be one click to open. Is there a manual setting to override the default behaviour of always opening a fat menu, even if there is only one item?

Here's a demo of what I'm experiencing:

Hoping there's a way to do this out of the box. If not, I'll have to do some customisations.

1 Like

I get your point. The current implementation of the fat menu in VLT is optimized for very large websites/organizations where the first three levels of a website are always filled with lots of content. We rather have the problem that the fat menu becomes "too fat". It works well for this particular use case (as fat menus in general).

I agree that it currently does not suit the small website use case very well. I could imagine that we add a configuration option for this.

However, what kind of behavior would you expect to happen if you have a site with a top-navigation entry that lacks subitems?

1 Like

I would expect the default behaviour for a top-level link (that has no children) to be as follows -- clicking it would result in going directly to that link (no dropdown).

I'm also okay with an option to mark a top-level link to "act as a direct link".

I have doubts regarding usability if clicking on one navigation item would open the fat menu and another one goes directly to the link. This violates the basic UX principle that "things that look similar, should act similar". The user has not way to see what would happen when she clicks on a navigation item.

I understand the value of consistency and predictability:

Here are two approaches:

  1. Tiny arrow on Fat menus


    Fat menus have a little arrow next to them, items without an arrow are direct links.

  2. Fat menu on hover only
    An example of this approach is apple.com. They have a fat menu on hover but clicking is always a direct link, typically to an associated landing page.

1 Like

We are currently experimenting with an "open menu on hover" option in a client project. If that works well I could imagine that we add a new option to VLT to open the menu on hover instead of click. Feel free to open a feature-request issue on the VLT issue tracker.

I see how option 1 solves the problem. Though, tbh. I am not a big fan of this approach. The differentiation is too subtle IMO.

For now I've decided to implement the "tiny arrow on fat menus" option for my project by shadowing the VLT Navigation and adding the needed behaviour.
My navbar now looks like this and it's doing what I need:

I'm using a dropdown icon on the fat menus as my indicator. Still a work in progress, but I expect we'll settle into a consistent experience over the next couple weeks.

Part of the reason why I've decided to go with the tiny arrow option is what is needed for the alternative. You have to be very careful with the "open on hover" option. The UX has to be right otherwise it becomes uncomfortable to use (menus that flash unto the screen and disappear as you hover and unhover). Ideally a bit of transition/animation, to help the user to understand what is happening, can really help.

2 Likes

Similar discussion is over here:

I played around with the Volto Light Theme fat menu on a test site and wanted to share a few improvements. These are all contained in a single shadowed component, so they're easy to drop into your own project.

What changed

The default VLT fat menu opens for every top-level navigation item, even standalone pages with no children. We wanted it to only open when there are actually sub-pages to show — and for standalone items to just navigate directly on click.

We also wanted subitem descriptions to appear in the fat menu panel, pulling from each page's standard Plone summary field. No backend changes needed — the description field is already returned by the navigation API.

Both fat-menu items and standalone items render as <button> elements so hover styling is uniform across the nav. A chevron indicator shows which items have a submenu.

How to use it

Shadow @kitconcept/volto-light-theme/components/Navigation/Navigation.jsx in your addon and replace it with the file below. Then add the SCSS to your addon's _main.scss.

You can control how many levels deep descriptions appear by changing this constant at the top of the file:

// 0 = all levels, 1 = first level only, 2 = first two levels, etc.
const DESCRIPTION_DEPTH_LIMIT = 0;

Navigation.jsx

// SemanticUI-free pre-@plone/components

import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { NavLink, useHistory } from 'react-router-dom';
import doesNodeContainClick from '@kitconcept/volto-light-theme/helpers/doesNodeContainClick';
import { useIntl, defineMessages, injectIntl } from 'react-intl';
import cx from 'classnames';
import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
import config from '@plone/volto/registry';

import { getNavigation } from '@plone/volto/actions/navigation/navigation';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import clearSVG from '@plone/volto/icons/clear.svg';
import downSVG from '@plone/volto/icons/down-key.svg';

const messages = defineMessages({
  closeMenu: {
    id: 'Close menu',
    defaultMessage: 'Close menu',
  },
  openFatMenu: {
    id: 'Open menu',
    defaultMessage: 'Open menu',
  },
});

// 0 = show descriptions at all levels, 1 = first level only, 2 = first two levels, etc.
const DESCRIPTION_DEPTH_LIMIT = 0;

const Navigation = ({ pathname }) => {
  const [desktopMenuOpen, setDesktopMenuOpen] = useState(null);
  const [currentOpenIndex, setCurrentOpenIndex] = useState(null);
  const navigation = useRef(null);
  const dispatch = useDispatch();
  const intl = useIntl();
  const history = useHistory();
  const headerSettings = useSelector(
    (state) =>
      state.content.data?.['@components']?.inherit?.['voltolighttheme.header']
        ?.data,
  );
  const formData = useSelector((state) => state.form.global);

  const has_fat_menu =
    !isEmpty(formData) && formData?.has_fat_menu
      ? formData.has_fat_menu
      : headerSettings?.has_fat_menu;

  const lang = useSelector((state) => state.intl.locale);
  const token = useSelector((state) => state.userSession.token, shallowEqual);
  const items = useSelector((state) => state.navigation.items, shallowEqual);

  useEffect(() => {
    const handleClickOutside = (e) => {
      if (navigation.current && doesNodeContainClick(navigation.current, e))
        return;
      closeMenu();
    };

    document.addEventListener('mousedown', handleClickOutside, false);

    return () => {
      document.removeEventListener('mousedown', handleClickOutside, false);
    };
  }, []);

  useEffect(() => {
    if (!hasApiExpander('navigation', getBaseUrl(pathname))) {
      dispatch(getNavigation(getBaseUrl(pathname), config.settings.navDepth));
    }
  }, [pathname, token, dispatch]);

  const isActive = (url) => {
    return (url === '' && pathname === '/') || (url !== '' && pathname === url);
  };

  const openMenu = (index) => {
    if (index === currentOpenIndex) {
      setDesktopMenuOpen(null);
      setCurrentOpenIndex(null);
    } else {
      setDesktopMenuOpen(index);
      setCurrentOpenIndex(index);
    }
  };

  const closeMenu = () => {
    setDesktopMenuOpen(null);
    setCurrentOpenIndex(null);
  };

  useEffect(() => {
    const handleEsc = (event) => {
      if (event.keyCode === 27) {
        closeMenu();
      }
    };
    window.addEventListener('keydown', handleEsc);

    return () => {
      window.removeEventListener('keydown', handleEsc);
    };
  }, []);

  return (
    <nav
      id="navigation"
      aria-label="navigation"
      className="navigation"
      ref={navigation}
    >
      <div className={'computer large screen widescreen only'}>
        <ul className="desktop-menu">
          {items.map((item, index) => (
            <li key={item.url}>
              {has_fat_menu && item.items && item.items.length > 0 ? (
                <>
                  <button
                    onClick={() => openMenu(index)}
                    className={cx('item', 'has-submenu', {
                      active:
                        desktopMenuOpen === index ||
                        (!desktopMenuOpen && pathname === item.url),
                      open: desktopMenuOpen === index,
                    })}
                    aria-label={intl.formatMessage(messages.openFatMenu)}
                    aria-expanded={desktopMenuOpen === index}
                  >
                    {item.title}
                    <Icon
                      name={downSVG}
                      size="16px"
                      className={cx('chevron', {
                        'chevron-up': desktopMenuOpen === index,
                      })}
                    />
                  </button>

                  <div className="submenu-wrapper">
                    <div
                      className={cx('submenu', {
                        active: desktopMenuOpen === index,
                      })}
                    >
                      <div className="submenu-inner">
                        <NavLink
                          to={item.url === '' ? '/' : item.url}
                          onClick={() => closeMenu()}
                          className="submenu-header"
                        >
                          <h2>{item.nav_title ?? item.title}</h2>
                        </NavLink>
                        <button
                          className="close"
                          onClick={closeMenu}
                          aria-label={intl.formatMessage(messages.closeMenu)}
                        >
                          <Icon name={clearSVG} size="48px" />
                        </button>
                        <ul>
                          {item.items.map((subitem) => (
                            <li className="subitem-wrapper" key={subitem.url}>
                              <NavLink
                                to={subitem.url}
                                onClick={() => closeMenu()}
                                className={cx({
                                  current: isActive(subitem.url),
                                })}
                              >
                                <span className="left-arrow">&#8212;</span>
                                <span>
                                  {subitem.nav_title || subitem.title}
                                </span>
                                {subitem.description &&
                                  (DESCRIPTION_DEPTH_LIMIT === 0 ||
                                    1 <= DESCRIPTION_DEPTH_LIMIT) && (
                                    <span className="subitem-description">
                                      {subitem.description}
                                    </span>
                                  )}
                              </NavLink>
                              <div className="sub-submenu">
                                <ul>
                                  {subitem.items &&
                                    subitem.items.length > 0 &&
                                    subitem.items.map((subsubitem) => (
                                      <li
                                        className="subsubitem-wrapper"
                                        key={subsubitem.url}
                                      >
                                        <NavLink
                                          to={subsubitem.url}
                                          onClick={() => closeMenu()}
                                          className={cx({
                                            current: isActive(subsubitem.url),
                                          })}
                                        >
                                          <span className="left-arrow">
                                            &#8212;
                                          </span>
                                          <span>
                                            {subsubitem.nav_title ||
                                              subsubitem.title}
                                          </span>
                                          {subsubitem.description &&
                                            (DESCRIPTION_DEPTH_LIMIT === 0 ||
                                              2 <= DESCRIPTION_DEPTH_LIMIT) && (
                                              <span className="subitem-description">
                                                {subsubitem.description}
                                              </span>
                                            )}
                                        </NavLink>
                                      </li>
                                    ))}
                                </ul>
                              </div>
                            </li>
                          ))}
                        </ul>
                      </div>
                    </div>
                  </div>
                </>
              ) : (
                <button
                  onClick={() => {
                    closeMenu();
                    history.push(item.url === '' ? '/' : item.url);
                  }}
                  className={cx('item', {
                    active: !desktopMenuOpen && pathname === item.url,
                  })}
                >
                  {item.title}
                </button>
              )}
            </li>
          ))}
        </ul>
      </div>
    </nav>
  );
};

Navigation.propTypes = {
  pathname: PropTypes.string.isRequired,
};

Navigation.defaultProps = {
  token: null,
};

export default injectIntl(Navigation);

SCSS

Add this to your addon's _main.scss:

a:has(.subitem-description) {
  flex-direction: column;
}

.subitem-description {
  display: block;
  font-size: 0.85em;
  opacity: 0.75;
  margin-top: 0.2em;
}

.navigation .chevron {
  transition: transform 0.2s ease;
}

.navigation .chevron-up {
  transform: rotate(180deg);
}

Notes

  • Tested on Volto 18.32.1 with @kitconcept/volto-light-theme 7.8.2
  • The description field is already included in Plone's navigation API response — no backend changes needed, just add a summary to your pages in the editor
  • The fat menu toggle (has_fat_menu) still comes from the kitconcept.voltolighttheme backend behaviors as normal — these changes sit on top of that existing mechanism
  • Would love to hear if anyone has tackled the accessibility side of the chevron button more thoroughly — we kept aria-expanded but there's probably more to do there

Happy to answer questions or take suggestions for improvement.

6 Likes

Thank you for sharing this Karel. We did something similar for certain client projects but didn’t had time to share this with the community.

2 Likes