In large-scale digital products, even seemingly straightforward components like dropdown menus can evolve into challenges. They become brittle with patchwork adjustments, inconsistent behaviors, and an accumulation of redundant code across teams. This article explores the thoughtful construction of a reusable, accessible action menu feature for share buttons, highlighting the importance of usability, scalability, and maintainability in modern web applications.

Tackling common challenges in scalable UI

Implementing dropdown menus in an ad hoc manner often leads to:

  1. Code duplication: Multiple teams might create their own versions, leading to redundant code and increased maintenance effort.
  2. Usability pitfalls: Inconsistent interactions, such as varying hover and click behaviors, can confuse users.
  3. Accessibility oversights: Lack of keyboard navigation support and improper ARIA roles can exclude users who rely on assistive technologies.
  4. Performance concerns: Inefficient code and unnecessary dependencies can degrade application performance.

A common pitfall: complexity disguised as progress

For example, one team might implement a dropdown using a specific animation library, while another uses a different approach. This leads to inconsistencies and makes debugging difficult, as developers need to understand multiple implementations. Additionally, without standardization, accessibility features might be overlooked, leading to a non-inclusive product.


Reflecting on these challenges through my experience in designing and engineering for large-scale applications, I prioritized crafting a dropdown menu that is scalable, accessible, and built to integrate seamlessly into existing infrastructures.

Building the reusable action menu

To address these challenges, I developed a modular action menu component using modern JavaScript (ES6), ensuring it's lightweight and framework-agnostic. The component can be integrated into projects regardless of whether they use frameworks like React, Angular, or Vue.js.

Key principles driving the solution

  1. Usability-first design: Intuitive interactions that work seamlessly with both mouse and keyboard.
  2. Scalable architecture: Configuration via HTML attributes, eliminating the need for redundant JavaScript.
  3. Accessibility compliance: Full support for ARIA roles, keyboard navigation, and focus management.
  4. Viewport intelligence: Dynamic positioning to ensure menus stay within the viewport.

Technical implementation details

Clean markup-driven controls

The component is configured using HTML data attributes, allowing for easy customization without modifying the JavaScript code.

<div class="action-container">
  <button
    class="action-button"
    data-trigger="click"
    data-type="share"
    aria-expanded="false"
  >
    Share
  </button>
  <div class="action-menu" role="menu">
    <a href="#" class="action-item" role="menuitem">Copy Link</a>
    <a href="#" class="action-item" role="menuitem">Share on Twitter</a>
    <!-- More items -->
  </div>
</div>

Why this approach?

  • Separation of concerns: Keeps behavior and structure separate, enhancing maintainability.
  • Ease of use: Developers can add or modify menus by updating the HTML markup alone.

The JavaScript module

The core functionality is encapsulated in a JavaScript module that handles event binding, menu toggling, and positioning.

// ActionButton.js

export function initActionButton({
  selector = '.action-button',
  containerSelector = '.action-container',
} = {}) {
  document.querySelectorAll(containerSelector).forEach((container) => {
    const button = container.querySelector(selector);
    const menu = container.querySelector('.action-menu');
    const triggerType = button.dataset.trigger || 'hover';

    if (triggerType === 'hover') {
      container.addEventListener('mouseenter', () => openActionMenu(button));
      container.addEventListener('mouseleave', () => closeActionMenu(button));
    } else {
      button.addEventListener('click', () => toggleActionMenu(button));
    }

    button.addEventListener('keydown', (event) => handleKeyDown(event, button));
  });

  // Core functions: openActionMenu, closeActionMenu, toggleActionMenu
  // Additional functions: adjustMenuPosition, handleKeyDown, closeAllMenus

  function openActionMenu(button) {
    const menu = button.nextElementSibling;
    closeAllMenus();
    menu.classList.add('open');
    button.classList.add('active');
    button.setAttribute('aria-expanded', 'true');

    adjustMenuPosition(button, menu);
  }

  function closeActionMenu(button) {
    const menu = button.nextElementSibling;
    menu.classList.remove('open');
    button.classList.remove('active');
    button.setAttribute('aria-expanded', 'false');
  }

  function toggleActionMenu(button) {
    const menu = button.nextElementSibling;
    const isOpen = menu.classList.contains('open');
    if (isOpen) {
      closeActionMenu(button);
    } else {
      openActionMenu(button);
    }
  }

  function closeAllMenus() {
    document.querySelectorAll('.action-menu.open').forEach((menu) => {
      menu.classList.remove('open');
      const button = menu.previousElementSibling;
      button.classList.remove('active');
      button.setAttribute('aria-expanded', 'false');
    });
  }
}

Architectural decisions:

  • Event delegation: Attaching events to containers reduces the number of event listeners and improves performance.
  • State management via classes: Using CSS classes like .open and .active simplifies state tracking and visual updates.

Dynamic positioning for edge cases

To ensure the menu stays within the viewport, especially when the trigger button is near the edges, the adjustMenuPosition function calculates available space and adjusts accordingly.

function adjustMenuPosition(button, menu) {
  // Reset position classes
  menu.classList.remove('align-left', 'align-right');

  const buttonRect = button.getBoundingClientRect();
  const menuRect = menu.getBoundingClientRect();
  const viewportWidth = window.innerWidth;

  if (buttonRect.left + menuRect.width > viewportWidth) {
    // Align to the right
    menu.classList.add('align-right');
  } else if (buttonRect.left < menuRect.width / 2) {
    // Align to the left
    menu.classList.add('align-left');
  } else {
    // Center align
    menu.style.left = '50%';
    menu.style.transform = 'translateX(-50%)';
  }
}

Performance considerations:

  • Efficient calculations: Using getBoundingClientRect() sparingly to minimize reflows.
  • CSS transforms: Leveraging hardware acceleration for smoother animations.

Accessibility and usability at the core

The component is designed to be fully accessible:

  • ARIA roles and attributes: Properly set roles like role="menu" and manage aria-expanded.
  • Keyboard navigation: Supports navigation using Tab, Enter, Space, and arrow keys.
function handleKeyDown(event, button) {
  const menu = button.nextElementSibling;
  if (event.key =<mark> 'Enter' || event.key </mark>= ' ') {
    event.preventDefault();
    toggleActionMenu(button);
    menu.querySelector('.action-item').focus();
  }
}

menu.addEventListener('keydown', (event) => {
  const items = Array.from(menu.querySelectorAll('.action-item'));
  const currentIndex = items.indexOf(document.activeElement);
  if (event.key === 'ArrowDown') {
    event.preventDefault();
    const nextIndex = (currentIndex + 1) % items.length;
    items[nextIndex].focus();
  } else if (event.key === 'ArrowUp') {
    event.preventDefault();
    const prevIndex = (currentIndex - 1 + items.length) % items.length;
    items[prevIndex].focus();
  } else if (event.key === 'Escape') {
    closeActionMenu(button);
    button.focus();
  }
});

Focus management:

  • Trap focus within menu: Ensures that focus cycles through menu items.
  • Return focus to trigger: When the menu is closed, focus returns to the trigger button.

Styling with SCSS

The SCSS styles handle the visual presentation and positioning of the menu.

.action-container {
  position: relative;
}

.action-menu {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  // Hidden by default
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s ease;

  &.open {
    opacity: 1;
    visibility: visible;
  }

  &.align-left {
    left: 0;
    transform: translateX(0);
  }

  &.align-right {
    right: 0;
    left: auto;
    transform: translateX(0);
  }
}

Why use CSS classes over inline styles?

Maintainability: Easier to update styles without changing JavaScript code.
Performance: Reduces layout thrashing by avoiding inline style changes.

Adapting for frameworks

While the component is built with vanilla JavaScript, it can be easily integrated into frameworks.

import { useEffect } from 'react';
import { initActionButton } from './ActionButton';

function ShareButton() {
  useEffect(() => {
    initActionButton();
  }, []);

  return (
    <div className="action-container">
      <button
        className="action-button"
        data-trigger="click"
        data-type="share"
        aria-expanded="false"
      >
        Share
      </button>
      <div className="action-menu" role="menu">
        <a href="#" className="action-item" role="menuitem">
          Copy Link
        </a>
        <a href="#" className="action-item" role="menuitem">
          Share on Twitter
        </a>
        {/* More items */}
      </div>
    </div>
  );
}

Considerations

Lifecycle : Use useEffect to initialize the component after the DOM is rendered.
Avoid direct DOM manipulation: Ensure that any DOM manipulations do not conflict with React’s virtual DOM.

Common customization scenarios

Changing the trigger type: Use data-trigger="hover" or data-trigger="click" to modify how the menu is activated.
Adding menu items: Simply add more <a> elements within the .action-menu div.
Styling adjustments: Customize the SCSS variables or override styles as needed.

Potential pitfalls and solutions

Menu closes unexpectedly: Ensure that the container includes any gaps caused by design elements like arrow indicators.
Focus lost on close: Confirm that focus management returns the focus to the trigger button after closing.

Inviting exploration

I invite you to try the link – a live implementation of the action menu component discussed here. If you found it useful, consider sharing it with others.