DEV Community

Cover image for 🌈 Building an Animated and Accessible Command Menu in React
Harsh Singh
Harsh Singh

Posted on

🌈 Building an Animated and Accessible Command Menu in React

Hey everyone! Over the past few months, I've been building my very own command menu component for React called kmenu! In this post, I want to go over how you can also create your OWN command menu.

I originally gave this talk at about building a command menu at React JAX, and the recording should be up soon! In the meanwhile, you can follow this tutorial or the CodeSandbox.

Before we begin, you can now check it out LIVE at over at kmenu.hxrsh.in, and get started with the documentation over on the GitHub -- if you enjoy it, don't forget to leave a star! I'm trying to reach 400 stars soon πŸŽ‰.

Background

Command menus have an interesting history, and the idea stemmed from basically having a simple and easy way to navigate through an application. The first example of this could be found in Sublime Text, as they announced the beta of Sublime Text 2. Pulled from their blog:

The Command Palette provides a quick way to access commands that don't warrant a key binding, and would usually be hidden away in a menu. For example, turning Word Wrap on or off, or changing the syntax highlighting mode of the current file. It uses the same fuzzy matching as Goto Anything does, meaning most commands are accessible with just a few key presses.

This worked like a power tool, and led to more exploration and higher feature discoverability leading to it being adopted by code editors like Visual Studio Code. It wasn't long until these command palettes made their way onto web applications.

Companies such as Vercel, GitHub, Sentry, Linear, Railway, Raycast and MANY other web applications ended up adding a command menu to their websites as well. Even desktop applications such as Discord, Figma or Slack feature a command menu to help users navigate their complex interfaces with ease.

With that, there are a few different approaches for adding a command menu to your website: you can use an open source library (like kmenu, cmdk, or kbar), use a proprietary tool such as CommandBar, or build your own. This tutorial focuses on building your own implementation, however you may not need to depending on whether or not you're satisfied with the other options available.

As said above, in this post, we'll be focusing on building our own command menu implementation. Our menu will be accessible, fully animated, and you can customise it to your needs!

πŸš€ Getting Started

Since we'll be using Tailwind CSS, I suggest you start by installing and configuring that. Skip over this if your project already uses Tailwind.

# Install Tailwind with peer dependencies
yarn add tailwindcss postcss autoprefixer -D
# Generate the Tailwind and PostCSS configuration
yarn tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Next, open up your tailwind.config.js and add in your code directories under content. This varies depending upon whether or not you're using something like Create React App or Next.js.

This is also the time to import in any fonts. For my project, I'm going to use Albert Sans, the brand new font on Google Fonts!

As a side note: I personally believe that Tailwind is an anti-pattern, but I've only used it for the purposes of building this command menu quicker. If your website doesn't already include Tailwind, I heavily advocate that you use a regular CSS approach as opposed to this.

Make sure you also install Framer Motion, a production-ready motion library for React. It'll help us in adding awesome motion animations to our command menu.

Adding Commands

First, we'll create a commands.tsx file at components/Menu, which will store all of our commands. I'm using react-icons, which essentially just bundles together all popular icon packs for easy use.

Anyways, let's begin by creating a Command type before we create our array of objects:

import { ReactElement } from 'react'

export type Command = {
  icon: ReactElement
  text: string
  perform: () => void
}
Enter fullscreen mode Exit fullscreen mode

It's super simple: just an icon, some text, and a perform event which happens on click.

Next, we'll create our command. Create an array of objects of type Command, and add in your commands:

// ...
import {
  SiFramer,
  SiTailwindcss,
  SiApple,
  SiVercel,
  SiTwitter,
  SiTesla,
  SiArchlinux,
  SiDeno,
  SiFlutter,
  SiGithub,
  SiNike,
  SiDiscord,
} from 'react-icons/si'

export const commands: Command[] = [
  {
    icon: <SiFramer />,
    text: 'Framer Motion',
    perform: () => window.open('https://framer.com/motion', '_blank'),
  },
  {
    icon: <SiTailwindcss />,
    text: 'TailwindCSS',
    perform: () => window.open('https://tailwindcss.com', '_blank'),
  },
  {
    icon: <SiApple />,
    text: 'Apple',
    perform: () => window.open('https://apple.com', '_blank'),
  },
  {
    icon: <SiArchlinux />,
    text: 'Arch Linux',
    perform: () => window.open('https://apple.com', '_blank'),
  },
  {
    icon: <SiVercel />,
    text: 'Vercel',
    perform: () => window.open('https://vercel.com', '_blank'),
  },
  {
    icon: <SiTesla />,
    text: 'Tesla',
    perform: () => window.open('https://tesla.com', '_blank'),
  },
  {
    icon: <SiDeno />,
    text: 'Deno',
    perform: () => window.open('https://deno.land', '_blank'),
  },
  {
    icon: <SiDiscord />,
    text: 'Discord',
    perform: () => window.open('https://discord.com', '_blank'),
  },
  {
    icon: <SiFlutter />,
    text: 'Flutter',
    perform: () => window.open('https://flutter.dev/', '_blank'),
  },
  {
    icon: <SiGithub />,
    text: 'GitHub',
    perform: () => window.open('https://github.com/', '_blank'),
  },
  {
    icon: <SiNike />,
    text: 'Nike',
    perform: () => window.open('https://nike.com/', '_blank'),
  },
  {
    icon: <SiTwitter />,
    text: 'Twitter',
    perform: () => window.open('https://twitter.com', '_blank'),
  },
]
Enter fullscreen mode Exit fullscreen mode

Building Our Menu

Now that we finished adding commands, let's get started building our menu. Start by creating the skeleton, which we'll gradually add features to.

const CommandMenu: FC = () => {
  return (
    <motion.div
      className='flex items-center justify-center overflow-hidden fixed w-screen h-screen select-none bg-[#e7e7e7e5]'
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
    >
      <motion.div
        className='w-[640px] will-change-auto relative bg-white rounded-lg shadow-2xl'
        role='dialog'
        aria-modal='true'
      >
        <div className='flex items-center h-16 text-xl pointer-events-none'>
          <FiSearch className='ml-4' />
          <input
            placeholder='Search for anything...'
            type='text'
            autoCapitalize='false'
            autoComplete='false'
            spellCheck='false'
            autoFocus
            className='ml-4 outline-none w-full pointer-events-none'
          />
        </div>
        <motion.ul
          className='flex overflow-y-auto overflow-x-hidden flex-col w-full transition-all will-change-auto max-h-[320px]'
          role='listbox'
        >
          {commands.map((command, index) => (
            // ...our command component, which we'll create later
          ))}
        </motion.ul>
      </motion.div>
    </motion.div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Creating The Command Component

Awesome, now that we have our skeleton, let's create our command component. This will be the thing that we map onto our command menu.

const Command: FC<{
  command: Command
}> = ({ command }) => {
  return (
    <li>
      <a
        href='#'
        className='flex relative text-lg items-center h-16 cursor-pointer'
        onClick={command.perform}
      >
        <div className='flex items-center relative w-full h-full ml-5'>
          {command.icon}
          <p className='max-w-[90%] w-fit m-0 overflow-hidden text-ellipsis text-lg text-black ml-2'>
            {command.text}
          </p>
        </div>
      </a>
      <div aria-hidden='true' ref={bottomRef} />
    </li>
  )
}
Enter fullscreen mode Exit fullscreen mode

Awesome. Now, let's head back onto our main component make sure map the command component.

{commands.map((command, index) => (
  <Command key={index} command={command} />
))}
Enter fullscreen mode Exit fullscreen mode

Cool. We should now see all of our commands on our screen! They may not look very pretty, but at least they work. πŸ˜…

Implementing Search

So, as you may have seen, this command menu has a search bar which you can use to search the commands.

For this, we'll simply use two hook which contains an array of objects called results and another hook for the current query:

import type { Command } from './commands.tsx'
// ...
const [query, setQuery] = useState('')
const [results, setResults] = useState<Command[] | null>(null)
Enter fullscreen mode Exit fullscreen mode

Next, we'll create a filter function to filter our commands based upon a string.

const filter = (query: string): Command[] =>
  commands.filter((command) =>
    command.text.toLowerCase().includes(query.toLowerCase())
  )
Enter fullscreen mode Exit fullscreen mode

Next, we'll use a useEffect hook to update the results every time the user types something onto the search bar.

useEffect(() => query ? setResults(filter(query)) : setResults(null), [query, setQuery])
Enter fullscreen mode Exit fullscreen mode

Awesome. Now, let's go inside our input component and make sure we update our setQuery hook on change.

<input
  placeholder='Search for anything...'
  type='text'
  autoCapitalize='false'
  autoComplete='false'
  spellCheck='false'
  autoFocus
  className='ml-4 outline-none w-full pointer-events-none'
  onChange={(event) => setQuery(event.currentTarget.value)}
/>
Enter fullscreen mode Exit fullscreen mode

Next, we need to make sure that we map our results instead of the original commands. Head back into your map function and substitute commands for results.

{results?.map((command, index) => (
  <Command key={index} command={command} />
))}
Enter fullscreen mode Exit fullscreen mode

Awesome. Type something into your search bar -- it should work!

Handling Selection States

Onto the next step: handling which command is currently selected. As you may have seen on the demo, each command could be selected with the keyboard, or if you hovered your mouse over it.

In kmenu, I used a Reducer for this, which is what I suggest you do if you're planning on expanding the features of this menu later on.

However, for this, we'll just use a simple useState hook:

const [selected, setSelected] = useState(0)
Enter fullscreen mode Exit fullscreen mode

Firstly, let's go inside our useEffect for updating our results and make sure we reset the state inside of there every time we search.

useEffect(() => {
  setSelected(0)
  return query ? setResults(filter(query)) : setResults(null)
}, [query, setQuery])
Enter fullscreen mode Exit fullscreen mode

Awesome. Let's create a navigation function which we'll wrap around a useCallback, which will contain logic for changing the selection with our keyboard.

This checks for things like whether or not we're at the start or end of our command menu, and also works with using the tab keys.

const navigation = useCallback(
  (event: KeyboardEvent) => {
    if (results !== null) {
      const length = results.length - 1

      if (
        event.key === 'ArrowUp' ||
        (event.key === 'Tab' && event.shiftKey)
      ) {
        event.preventDefault()
        setSelected(() => (selected === 0 ? 0 : selected - 1))
      } else if (event.key === 'ArrowDown' || event.key === 'Tab') {
        event.preventDefault()
        setSelected(selected === length ? length : selected + 1)
      }
    }
  },
  [results, selected]
)
Enter fullscreen mode Exit fullscreen mode

Make sure you create a new useEffect hook for adding and removing event listeners.

useEffect(() => {
  window.addEventListener('keydown', navigation)
  return () => window.removeEventListener('keydown', navigation)
}, [navigation])
Enter fullscreen mode Exit fullscreen mode

Cool. Let's now go into our Command component and add in props to handle selection.

const Command: FC<{
  command: Command
  onMouseMove: () => void
  selected: boolean
}> = ({ command, onMouseMove, selected }) => {
Enter fullscreen mode Exit fullscreen mode

...and since we've done that, we need to change our map accordingly.

{results?.map((command, index) => (
  <Command
    key={index}
    onMouseMove={() => setSelected(index)}
    selected={selected === index}
    command={command}
  />
))}
Enter fullscreen mode Exit fullscreen mode

Next, we'll create a perform function which would be inside a useCallback hook, essentially checking whether or not the user has pressed the enter key and if the command is currently selected. If it is, then just run the perform function.

const perform = useCallback(
  (event: KeyboardEvent) => {
    if (event.key === 'Enter' && selected) return command.perform()
  },
  [command, selected]
)

useEffect(() => {
  window.addEventListener('keydown', perform)
  return () => window.removeEventListener('keydown', perform)
})
Enter fullscreen mode Exit fullscreen mode

Let's also make sure we select a command when we hover over it. For this, we'll pass in our onMouseMove prop onto our element:

<a
  href='#'
  className='flex relative text-lg items-center h-16 cursor-pointer'
  onMouseMove={onMouseMove}
  onClick={command.perform}
>
Enter fullscreen mode Exit fullscreen mode

We should now be able to navigate through our command menu with our arrow keys and enter, but we have no indication of which component we're currently on. To do so, let's add a bar which smoothly animates over onto our component when we select it.

{selected && (
  <motion.div
    layoutId='box'
    className='bg-[#00000010] absolute w-full h-16'
    initial={false}
    aria-hidden='true'
    transition={{
      type: 'spring',
      stiffness: 1000,
      damping: 70,
    }}
  />
)}
Enter fullscreen mode Exit fullscreen mode

We're making some awesome progress, and our command menu should now be navigable with our arrow keys or hovering over options.

Toggling The Menu

Let's move onto another important toggling our command menu. We'll also be using a hook for this:

const [open, setOpen] = useState(false)
Enter fullscreen mode Exit fullscreen mode

Awesome. Let's create a function called toggle, in which we handle the state of the bar for both computers and mobile phones.

const toggle = (event: KeyboardEvent) => {
  if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
    event.preventDefault()
    setOpen((open) => !open)
    setQuery('')
    setResults(null)
  }

  if (event.key === 'Escape') {
    setOpen(false)
    setQuery('')
    setResults(null)
  }
}

const mobileToggle = (event: TouchEvent) => {
  if (event.touches.length >= 2) {
    event.preventDefault()
    setOpen((open) => !open)
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, it checks if we've pressed cmd+k on our keyboard (if we're on PC) to toggle our menu. If instead we've pressed Escape, it closes our menu. For mobile phones, it just checks if the user has double tapped on their screen to close the menu. We will also work on toggling the menu if the user clicks outside later.

Anyways, add more event listeners for these commands:

useEffect(() => {
  window.addEventListener('keydown', toggle)
  window.addEventListener('touchstart', mobileToggle)
  return () => {
    window.removeEventListener('keydown', toggle)
    window.removeEventListener('touchstart', mobileToggle)
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

Let's also go inside our navigation command and check if our bar is actually open so we don't disable tab functionality.

Just add a simple check at the top of the if-statement, and also add it to the dependency array:

const navigation = useCallback(
  (event: KeyboardEvent) => {
    if (results !== null && open) {
      const length = results.length - 1

      if (
        event.key === 'ArrowUp' ||
        (event.key === 'Tab' && event.shiftKey)
      ) {
        event.preventDefault()
        setSelected(() => (selected === 0 ? 0 : selected - 1))
      } else if (event.key === 'ArrowDown' || event.key === 'Tab') {
        event.preventDefault()
        setSelected(selected === length ? length : selected + 1)
      }
    }
  },
  [results, selected, open]
)
Enter fullscreen mode Exit fullscreen mode

After we've done that, we also need to make that our bar actually closes after we run a command. For this, we need to pass it in as a prop on the component:

const Command: FC<{
  command: Command
  onMouseMove: () => void
  selected: boolean
  setOpen: Dispatch<SetStateAction<boolean>>
}> = ({ command, onMouseMove, selected, setOpen }) => {
Enter fullscreen mode Exit fullscreen mode

...and after we've done that, let's just update our map function:

{results?.map((command, index) => (
  <Command
    key={index}
    onMouseMove={() => setSelected(index)}
    selected={selected === index}
    command={command}
    setOpen={setOpen}
  />
))}
Enter fullscreen mode Exit fullscreen mode

Now, inside of our perform function, just close the menu and add the hook to the dependency array.

const perform = useCallback(
  (event: KeyboardEvent) => {
    if (event.key === 'Enter' && selected) {
      setOpen(false)
      return command.perform()
    }
  },
  [command, selected, setOpen]
)
Enter fullscreen mode Exit fullscreen mode

Let's finish this off by actually creating a toggle state for the bar and toggling its appearance, we'll be using AnimatePresence to animate components as they're removed from the React tree.

Let's move our entire component inside of this:

import { AnimatePresence } from 'framer-motion'
// ...
return (
  <AnimatePresence>
    {open && (
      <motion.div
        className='flex items-center justify-center overflow-hidden fixed w-screen h-screen select-none bg-[#e7e7e7e5]'
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
      >
        <motion.div
          className='w-[640px] will-change-auto relative bg-white rounded-lg shadow-2xl'
          role='dialog'
          aria-modal='true'
          initial={{ opacity: 0, y: 40 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: 40 }}
        >
          <div className='flex items-center h-16 text-xl pointer-events-none'>
            <FiSearch className='ml-4' />
            <input
              placeholder='Search for anything...'
              type='text'
              autoCapitalize='false'
              autoComplete='false'
              spellCheck='false'
              autoFocus
              className='ml-4 outline-none w-full pointer-events-none'
              onChange={(event) => setQuery(event.currentTarget.value)}
            />
          </div>
          <motion.ul
            className='flex overflow-y-auto overflow-x-hidden flex-col w-full transition-all will-change-auto max-h-[320px]'
            role='listbox'
          >
            {results?.map((command, index) => (
              <Command
                key={index}
                onMouseMove={() => setSelected(index)}
                selected={selected === index}
                command={command}
                setOpen={setOpen}
              />
            ))}
          </motion.ul>
        </motion.div>
      </motion.div>
    )}
  </AnimatePresence>
)
Enter fullscreen mode Exit fullscreen mode

Dynamically Setting Menu Height

If you saw the bar at the beginning, you have noticed that it expanded and shrunk based upon the amount of results. To do this, we can simply toggle the style prop of our listbox element containing our commands.

Since each command is 64px in height (you can check this through inspect element), we need to multiply our result length by 64 and set our max-height to a multiple of 64, depending on how many components we want on there at max. We also need to toggle overflow depending on this same parameter.

<motion.ul
  className='flex overflow-y-auto overflow-x-hidden flex-col w-full transition-all will-change-auto max-h-[320px]'
  role='listbox'
  style={{
    height: results !== null ? results!.length * 64 : 0,
    overflowY:
      results !== null && results.length >= 5 ? 'scroll' : 'hidden',
  }}
>
Enter fullscreen mode Exit fullscreen mode

Awesome. Our menu should be resizing smoothly too, as we have a transition property on there.

Scrolling Our Menu

We're almost done, but we still have something crucial left: scrolling our menu if we navigate it with our keyboard.

Thankfully, we have an awesome Element.scrollTo() function for this purpose. We'll also be using the Intersection Observer API to observe changes in the intersection of our listbox and command.

For this, we'll begin by creating a custom React hook called useInView, simply taking in a ref and telling us if it's visible within the parent.

import { RefObject, useEffect, useState } from 'react'

const useInView = (ref: RefObject<HTMLDivElement>) => {
  const [intersecting, setIntersecting] = useState(false)
  const observer = new IntersectionObserver(([entry]) =>
    setIntersecting(entry.isIntersecting)
  )

  useEffect(() => {
    observer.observe(ref.current!)
    return () => observer.disconnect()
  }, [ref])

  return intersecting
}

export default useInView
Enter fullscreen mode Exit fullscreen mode

Great. We now need two refs and two hooks to detect if it's in view at the top or the bottom, and scrolling based upon that. This might be a bit of a hacky/duct-tape solution, but it works.

If you're unsatisfied with this approach, it may be worth checking out the react-intersection-observer package.

const topRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)

const inViewTop = useInView(topRef)
const inViewBottom = useInView(bottomRef)
Enter fullscreen mode Exit fullscreen mode

Let's create two divs, and since they're non-interactive, we'll pass in aria-hidden to hide it from the accessibility API.

const Command: FC<{
  command: Command
  onMouseMove: () => void
  selected: boolean
  setOpen: Dispatch<SetStateAction<boolean>>
}> = ({ command, onMouseMove, selected, setOpen }) => {
  const topRef = useRef<HTMLDivElement>(null)
  const bottomRef = useRef<HTMLDivElement>(null)
  // ...
  return (
    <li>
      <div aria-hidden='true' ref={topRef} />
      <a
        href='#'
        className='flex relative text-lg items-center h-16 cursor-pointer'
        onMouseMove={onMouseMove}
        onClick={command.perform}
      >
        {selected && (
          <motion.div
            layoutId='box'
            className='bg-[#00000010] absolute w-full h-16'
            initial={false}
            aria-hidden='true'
            transition={{
              type: 'spring',
              stiffness: 1000,
              damping: 70,
            }}
          />
        )}
        <div className='flex items-center relative w-full h-full ml-5'>
          {command.icon}
          <p className='max-w-[90%] w-fit m-0 overflow-hidden text-ellipsis text-lg text-black ml-2'>
            {command.text}
          </p>
        </div>
      </a>
      <div aria-hidden='true' ref={bottomRef} />
    </li>
  )
}
Enter fullscreen mode Exit fullscreen mode

Great. Now that we have that, let's create a useEffect hook which takes care of scrolling our menu. We'll just check if the component is selected, and if it's viewable or not. If it isn't, then we'll just simply scroll to the element.

useEffect(() => {
  if (selected && (!inViewTop || !inViewBottom))
    bottomRef.current?.scrollIntoView({
      behavior: 'smooth',
      block: 'end',
    })
}, [inViewTop, inViewBottom, selected])
Enter fullscreen mode Exit fullscreen mode

Awesome, and that's all! Our listbox should now be automatically scrollable if we navigate it with our arrow keys.

Final Touches

We're almost done with our menu, and the functionality is nearly complete! Before we finish, we have to check if the user has clicked outside our menu, and close it accordingly.

For this, we'll create a custom hook which takes in a ref and a handler. We'll use event listeners for mobile and mouse to detect if the click or touch was inside our ref and close our menu accordingly.

import { RefObject, useEffect } from 'react'

const useClickOutside = (
  ref: RefObject<HTMLDivElement>,
  handler: () => void
) => {
  useEffect(() => {
    const listener = (event: MouseEvent) => {
      if (!ref.current || ref.current.contains(event.target as Node)) return
      handler()
    }

    const mobileListener = (event: TouchEvent) => {
      if (!ref.current || ref.current.contains(event.target as Node)) return
      handler()
    }

    document.addEventListener('mousedown', listener)
    document.addEventListener('touchstart', mobileListener)
    return () => {
      document.removeEventListener('mousedown', listener)
      document.removeEventListener('mousedown', listener)
    }
  }, [handler, ref])
}

export default useClickOutside
Enter fullscreen mode Exit fullscreen mode

Inside our main component, let's just import it in and create a new ref for our menu.

import useClickOutside from '@hooks/useClickOutside'

const menuRef = useRef<HTMLDivElement>(null)
useClickOutside(menuRef, () => setOpen(false))
Enter fullscreen mode Exit fullscreen mode

Additionally, make sure to actually assign it to your dialog component:

<motion.div
  className='w-[640px] will-change-auto relative bg-white rounded-lg shadow-2xl'
  role='dialog'
  aria-modal='true'
  initial={{ opacity: 0, y: 40 }}
  animate={{ opacity: 1, y: 0 }}
  exit={{ opacity: 0, y: 40 }}
  ref={menuRef}
>
Enter fullscreen mode Exit fullscreen mode

Sheesh! We just built our own command menu. As a side note, you can also change the placeholder colour inside our input to match your needs:

input::placeholder {
  @apply text-[#adb5bd];
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Anyways, with that, we're finished! If you have any errors setting up your command menu, feel free to comment down below or check out the CodeSandbox for reference.

I'm curious to see what you make with this command menu! I think a good step forward to scaling this menu would be adding things like keywords, categories, or nested commands. Feel free to share some cool things you've made with this command menu!

Do remember to give kmenu a glance if you're interested in it, it's packed with awesome features and functionality.

That's all for today, until next time πŸ‘‹.

Top comments (1)

Collapse
 
harshhhdev profile image
Harsh Singh

Hey Luke! I appreciate your feedback, and your points are valid.
I will go back in and fix those things as soon as possible. Thanks for pointing it out.