Skip to content

Shadcn KBD UI: Creating a Powerful ⌘K Command Menu

Written By Ajay Patel
13 min read

Shadcn KBD UI: Creating a Powerful ⌘K Command Menu

In this article, we will explore how to implement a fuzzy search interface with keyboard shortcuts in your project using shadcn/ui components such as Command and Kbd. This guide demonstrates the integration with a Next.js project, but the same approach can be adapted to other frameworks or tech stacks. Additionally, we will discuss the Shadcn KBD UI, which enhances the overall functionality.

The goal is to create a seamless, keyboard-driven interface that enhances user experience by allowing quick navigation and search functionality. By leveraging the power of shadcn/ui and the Shadcn KBD UI, we can build a modern and efficient command palette that is both functional and visually appealing.

What is Shadcn KBD Component and Why Use it?

shadcn kbd

The Kbd (Keyboard) component in shadcn/ui is a small UI element used to display keyboard keys and shortcuts in a visually clear, consistent way.

Instead of writing plain text like:

  • Press Ctrl + K

You present it as:

  • [Ctrl] + [K]

It mimics real keyboard keys, making shortcuts easier to scan and understand.

What does Shadcn KBD actually do?

  • Wraps text (like Ctrl, Cmd, Enter) in a styled key-like box

  • Helps group multiple keys into shortcut combinations

  • Improves readability + UX clarity

Why Use Shadcn KBD?

🚀 Makes Shortcuts Discoverable:

Users often don’t know shortcuts exist.

With Kbd:

  • You show, not tell

  • Example: Search → ⌘ + K

Improves UX Clarity

Plain text gets ignored. Visual keys stand out.

  • ❌ “Press Ctrl + S”

  • ✅ [Ctrl] + [S]

Faster recognition, less cognitive load

Builds Power-User Experience

Apps like:

  • Notion

  • Linear

  • VS Code

…all highlight shortcuts visually.

Kbd helps you achieve that same pro-level polish.

Perfect for Key UI Moments:

Use it in:

  • Buttons → Save [⌘ + S]

  • Tooltips → New Note [⌘ + N]

  • Search bars → [Ctrl + K]

  • Onboarding flows → “Try [Ctrl + K] to search”

Adds UI Polish (Tiny Detail, Big Impact):

It’s a small component, but:

  • Makes your UI feel intentional

  • Improves perceived quality

  • Signals attention to detail

When To Use Shadcn KBD?

Use Kbd if your app has:

  • Keyboard shortcuts

  • Command palette

  • Productivity workflows

  • Developer tools/dashboards

  • Power-user features

Example:

<Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>

The Shadcn Kbd component is not just UI; it’s UX communication.

It helps you:

  • Teach users faster

  • Reduce friction

  • Make your app feel premium

Command search functionality takes user experience to the next level by enabling users to quickly find and navigate to specific pages or sections within your site. Instead of manually browsing through menus or links, users can instantly locate what they need, saving time and reducing frustration.

Additionally, integrating keyboard shortcuts for frequently accessed pages further enhances usability. This feature allows users to perform actions or navigate to pages with a simple key combination, making the interface more intuitive and efficient. For content-heavy websites or applications, this functionality is invaluable in improving accessibility and user satisfaction.

The Shadcn KBD feature allows users to utilize keyboard shortcuts effectively, making navigation faster and more intuitive.

Brief of Implementation

To better understand the implementation, we will create a basic blog site with the following pages:

  • Home Page

  • Blog Page

  • About Us Page

  • Contact Us Page

The site will also include a Header and Footer for consistent navigation. To save time, we will use pre-built blocks from shadcn/studio, which provides free and premium blocks, components, and themes.

For the search functionality, we will use the Command, Dialog, and Kbd components from shadcn/ui. These components will allow us to create a modern, keyboard-driven command palette and fuzzy search capabilities.

Basic shadcn/ui Project Setup

If you’re integrating this command palette feature into an existing Next.js project, you can skip this section. However, if you’re starting fresh, follow the official shadcn/ui installation guide to set up your project correctly.

For this implementation, we’re using a basic Next.js project named shadcn-cmdk-search with Neutral as the base color theme. You can customize the color scheme and project name according to your preferences.

Once your Next.js and shadcn/ui setup is complete, ensure you have the following components installed from shadcn/ui:

  • Command - For search and command functionality

  • Dialog - For the modal overlay

  • Kbd - For displaying keyboard shortcuts

  • Button - For interactive elements

You can install these components using the shadcn/ui CLI:

npx shadcn-ui@latest add command dialog kbd button

Add Layout

For the header and footer, you can use free shadcn blocks from Shadcn Studio, such as the Shadcn Navbar and Shadcn Footer. These can be quickly integrated into your project using the provided registry.

After obtaining the code for components/layout/Header.tsx and components/layout/Footer.tsx, import and use them in your main layout file:

app/layout.tsx

import Footer from '@/components/layout/Footer'
import Header from '@/components/layout/Header'
...

const navigationData = [
  ...
]

export default function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang='en' data-scroll-behavior='smooth' className='h-full'>
      <body className={`${geistSans.variable} ${geistMono.variable} flex h-full flex-col antialiased`}>
        <Header navigationData={navigationData} />
        <div className='mx-auto w-full max-w-350 flex-1 border-x'>{children}</div>
        <Footer />
      </body>
    </html>
  )
}

This structure ensures a consistent layout across all pages, with navigation and footer components easily reusable.

Add Pages

Now add your desired pages. Use app/page.tsx for the home page, and create /about-us and /contact-us pages. For the content of these pages, you can utilize free blocks from shadcn/studio, such as the Hero Section, Pricing Component, About Us Page, and Contact Us Page.

This approach allows you to quickly scaffold visually appealing and functional pages, ensuring consistency and saving time in development. Each page can be customized further to match your brand and requirements.

Set up Search Data

They searchData will be defined in assets/data/search.ts to provide the structured data source for the command palette’s search functionality.

search.ts

// React Imports
import type { ForwardRefExoticComponent, RefAttributes } from 'react'

// Third-party Imports
import { CircleIcon, type LucideProps } from 'lucide-react'

type SearchData = {
  title: string
  data: {
    icon: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
    name: string
    href: string
    openInNewTab?: boolean
  }[]
}

export const searchData: SearchData[] = [
  {
    title: 'Pages',
    data: [
      {
        icon: CircleIcon,
        name: 'Home',
        href: '/'
      },
      ...
    ]
  },
  {
    title: 'Sections',
    data: [
      {
        icon: CircleIcon,
        name: 'Pricing',
        href: '/#pricing'
      }
    ]
  }
]
  • Type Definition: The SearchData type describes an array of groups. Each group contains:
    • title :The group’s display name (e.g., “Pages”, “Sections”).

    • data : An array of searchable items, each with:

      • icon : The Lucide icon component to display.

      • name : The display name of the item.

      • href : The navigation destination when selected.

      • openInNewTab (optional): Opens the link in a new tab if true.

This structure allows for organized grouping of search results and flexible navigation options.

Create CommandMenu component

Now we will create the CommandMenu component using the Command and Dialog components from shadcn/ui. The Command component provides the search functionality and fuzzy matching powered by the cmdk library. The Dialog component creates an overlay modal that displays on top of any page.

CommandMenu.tsx

'use client'

// React Imports
import { Fragment, useCallback, useEffect, useState } from 'react'

// Next Imports
import { useRouter } from 'next/navigation'

// Third-party Imports
import { DollarSignIcon, LayoutPanelTopIcon, PanelTopIcon, PhoneIcon, SearchIcon, SparklesIcon } from 'lucide-react'

// Component Imports
import { Button } from '@/components/ui/button'
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator
} from '@/components/ui/command'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Kbd } from '@/components/ui/kbd'

// Data Imports
import { searchData } from '@/assets/data/search'

const CommandMenu = () => {
  // States
  const [open, setOpen] = useState(false)
  const [search, setSearch] = useState('')

  // Hooks
  const router = useRouter()

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if ((e.key === 'k' && (e.metaKey || e.ctrlKey))) {
        if (
          (e.target instanceof HTMLElement && e.target.isContentEditable) ||
          e.target instanceof HTMLInputElement ||
          e.target instanceof HTMLTextAreaElement ||
          e.target instanceof HTMLSelectElement
        ) {
          return
        }

        e.preventDefault()
        setOpen(open => !open)
      }
    }

    document.addEventListener('keydown', down)

    return () => document.removeEventListener('keydown', down)
  }, [])

  const runCommand = useCallback((command: () => unknown) => {
    setOpen(false)
    command()
  }, [])

  return (
    <>
      <Button variant='outline' onClick={() => setOpen(true)} className='w-50 justify-between max-sm:hidden'>
        <div className='flex items-center gap-2'>
          <SearchIcon />
          <span>Search</span>
        </div>
        <Kbd>⌘ + K</Kbd>
      </Button>
      <Button variant='outline' size='icon' onClick={() => setOpen(true)} className='sm:hidden'>
        <SearchIcon />
        <span className='sr-only'>Search</span>
      </Button>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogHeader className='sr-only'>
          <DialogTitle>Search...</DialogTitle>
          <DialogDescription>Search for docs, blocks, components, and more.</DialogDescription>
        </DialogHeader>
        <DialogContent className='overflow-hidden p-0'>
          <Command
            className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'
            filter={(value, search) => {
              search = search.toLowerCase()
              value = value.toLowerCase()

              // Exact match with item name (highest priority)
              if (value === search) return 2

              // Partial match with item name (medium priority)
              if (value.includes(search)) return 1.5

              return 0
            }}
          >
            <CommandInput placeholder='Type a command or search...' value={search} onValueChange={setSearch} />
            <CommandList>
              <CommandEmpty>No results found.</CommandEmpty>
              {search ? (
                searchData.map((searchGroup, index) => (
                  <Fragment key={index}>
                    <CommandGroup heading={searchGroup.title}>
                      {searchGroup.data.map((item, i) => (
                        <CommandItem
                          key={i}
                          onSelect={() =>
                            runCommand(() => {
                              if (item.openInNewTab) {
                                window.open(item.href, '_blank', 'noopener,noreferrer')
                              } else {
                                router.push(item.href)
                              }
                            })
                          }
                        >
                          <item.icon />
                          <span>{item.name}</span>
                        </CommandItem>
                      ))}
                    </CommandGroup>
                    {index !== searchData.length - 1 && <CommandSeparator />}
                  </Fragment>
                ))
              ) : (
                <CommandGroup heading='Suggestions'>
                  <CommandItem onSelect={() => runCommand(() => router.push('/'))}>
                    <SparklesIcon />
                    <span>Home</span>
                  </CommandItem>
                  ...
                </CommandGroup>
              )}
            </CommandList>
          </Command>
        </DialogContent>
      </Dialog>
    </>
  )
}

export default CommandMenu

How it works:

  • State Management: The CommandMenu component uses React state to control the visibility of the command palette (open) and to track the current search input (search).

  • Keyboard Listener: A useEffect hook adds a global keyboard event listener for ⌘ + K (on Mac) or Ctrl + K (on Windows/Linux). This allows users to open or close the command palette from anywhere in the app, unless they’re focused on an input or editable element.

  • Search Data:

    • Imports searchData for dynamic search groups.

    • Each item has a name, icon, link, and an option to open in a new tab.

  • Filtering Logic: The filter prop on the <Command> component defines how items are matched:

    • Converts both the item value and the search input to lowercase.
    • Returns 2 for an exact match.
    • Returns 1.5 for a partial match (if the item includes the search).
    • Returns 0 otherwise.This ranking determines which items appear and in what order.
  • Suggestions: When no search is entered, shows default navigation options.

Add CommandMenu in Header

Now, integrate the CommandMenu component into your Header.tsx so users can easily access the search functionality from any page.

Header.tsx

'use client'

// Next.js Imports
import Link from 'next/link'
import { usePathname } from 'next/navigation'

// Third-party Imports
import { MenuIcon } from 'lucide-react'

// Component Imports
import { Button } from '@/components/ui/button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import CommandMenu from './CommandMenu'

// Utils Imports
import { cn } from '@/lib/utils'

type NavigationItem = {
  title: string
  href: string
}[]

const Header = ({ navigationData }: { navigationData: NavigationItem }) => {
  const pathname = usePathname()

  return (
    <header className='bg-background sticky top-0 z-50 border-b'>
      <div className='mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-7 sm:px-6'>
        <div className='text-muted-foreground flex flex-1 items-center gap-8 font-medium md:justify-center lg:gap-16'>
          <Link href='/' className={cn('hover:text-primary max-md:hidden', pathname === '/' && 'text-primary')}>
            Home
          </Link>
          ...
        </div>

        <div className='flex items-center gap-2'>
          <CommandMenu /> {/* Here we added the CommandMenu */}
          ...
        </div>
      </div>
    </header>
  )
}

export default Header

This ensures the command palette is always accessible from the site header, providing users with a consistent and efficient search experience.

Add shortcut keys functionality

To further enhance navigation, you can assign keyboard shortcuts to frequently accessed pages. This allows users to jump directly to a page using a key combination, such as ⌘ + B for the Blog page.

To implement this, update your searchData structure to include shortcutText (for display) and shortcutKey (for logic):

search.ts

// React Imports
import type { ForwardRefExoticComponent, RefAttributes } from 'react'

// Third-party Imports
import { CircleIcon, type LucideProps } from 'lucide-react'

type SearchData = {
  title: string
  data: {
    icon: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
    name: string
    href: string
    shortcutText?: string
    shortcutKey?: string
    openInNewTab?: boolean
  }[]
}

export const searchData: SearchData[] = [
  {
    title: 'Pages',
    data: [
      {
        icon: CircleIcon,
        name: 'Home',
        shortcutText: '⌘ + O',
        shortcutKey: 'o',
        href: '/#hero'
      },
      {
        icon: CircleIcon,
        name: 'Blog',
        shortcutText: '⌘ + B',
        shortcutKey: 'b',
        href: '/blog'
      },
      ...
    ]
  },
  ...
]

Then, in your CommandMenu component, listen for these shortcuts globally:

CommandMenu.tsx

// Inside CommandMenu.tsx useEffect
useEffect(() => {
  const down = (e: KeyboardEvent) => {
    if ((e.metaKey || e.ctrlKey) && !open) {
      searchData.forEach(group => {
        group.data.forEach(item => {
          if (item.shortcutKey && e.key.toLowerCase() === item.shortcutKey) {
            e.preventDefault()
            if (item.openInNewTab) {
              window.open(item.href, '_blank', 'noopener,noreferrer')
            } else {
              router.push(item.href)
            }
          }
        })
      })
    }
  }
  document.addEventListener('keydown', down)
  return () => document.removeEventListener('keydown', down)
}, [open, router])

And display the shortcut in the command palette UI:

{item.shortcutText && (
  <CommandShortcut>
    <Kbd className='bg-background border'>{item.shortcutText}</Kbd>
  </CommandShortcut>
)}

This setup allows users to quickly access important pages using custom keyboard shortcuts, improving efficiency and user experience.

Add tags/keywords functionality

To make your command palette even more intuitive, you can add tags or keywords to each search item. This allows users to find pages using related or industry-specific terms, improving the fuzzy search experience.

Update your searchData structure to include an optional tags array for each item:

search.ts

type SearchData = {
  title: string
  data: {
    icon: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
    name: string
    href: string
    shortcutText?: string
    shortcutKey?: string
    openInNewTab?: boolean
    tags?: string[]
  }[]
}

export const searchData: SearchData[] = [
  {
    title: 'Pages',
    data: [
      ...
      {
        icon: CircleIcon,
        name: 'Contact Us',
        shortcutText: '⌘ + I',
        shortcutKey: 'i',
        href: '/contact-us',
        tags: ['contact', 'get in touch', 'support']
      }
    ]
  },
  ...
]

Now, update your CommandMenu component to enhance the filter logic so it also matches against tags:

CommandMenu.tsx

// Inside CommandMenu.tsx
<Command
  filter={(value, search, keywords) => {
    search = search.toLowerCase()
    value = value.toLowerCase()

    if (value === search) return 2
    if (value.includes(search)) return 1.5

    // Match in tags/keywords
    if (keywords && keywords.length > 0) {
      if (keywords.some(keyword => keyword.toLowerCase() === search)) return 1.25
      const extendedValue = value + ' ' + keywords.join(' ').toLowerCase()
      if (extendedValue.includes(search)) return 1
    }

    return 0
  }}
>
  {/* ... */}
</Command>

And pass the tags as keywords to each CommandItem:

<CommandItem
  keywords={item.tags}
  // ...
>
  {/* ... */}
</CommandItem>

With this setup, users can search for pages using both their names and any relevant tags or keywords you define, making the command palette more flexible and user-friendly.

Set up a search for dynamic pages

In Next.js, you may want to provide search functionality for dynamic pages, such as blog posts or user profiles.

Add dynamic pages

First, create your dynamic pages. For example, you might have blog posts at blog/[blog]/page.tsx and a data file like assets/data/blogData.tsx containing metadata for each blog post.

Add dynamic pages to the search data

To include these dynamic pages in your command palette, map over your dynamic data and inject it into the searchData array:

search.ts

import blogData from './blogData'

type SearchData = {
  title: string
  data: {
    icon: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
    name: string
    href: string
    shortcutText?: string
    shortcutKey?: string
    openInNewTab?: boolean
    tags?: string[]
  }[]
}

export const searchData: SearchData[] = [
  ...
  {
    title: 'Blog Posts',
    data: [
      ...blogData.map(category => ({
        icon: CircleIcon,
        name: category.title,
        href: `/blog/${category.slug}`
      }))
    ]
  }
]

This approach ensures your command palette always includes up-to-date links to all dynamic content, making navigation seamless for users.

Resources

Summary

By following this guide, you have implemented a modern, keyboard-driven command palette with fuzzy search, command groups, and keyboard shortcuts using shadcn/ui and Next.js.

This feature not only streamlines navigation and improves accessibility but also delivers a polished, developer-friendly experience that users expect from contemporary web applications.

The approach is flexible and can be extended to support dynamic content, custom tags, and advanced filtering as your project grows.