Skip to content
AKAVA
AKAVA / navigation
open console
← All notes

Build an Accessible Cmd+K Command Palette in JavaScript

This article is for developers looking for a practical Cmd+K command palette, accessible site search, vanilla JavaScript search UI, WordPress search alternatives, Spotlight-style navigation, and keyboard-first website navigation.

There is a small problem with most website navigation that we rarely describe as a problem.

The user has to understand our information architecture before they can reach their goal.

Suppose somebody wants to improve site speed.

Our menu might expect this journey:

Services
    ↓
Development
    ↓
WordPress
    ↓
Performance

But the user’s mental model is much shorter:

speed

Why not let them say that?

I have been experimenting with a Site Command Palette that opens with ⌘K on macOS or Ctrl+K elsewhere and acts as a second navigation layer over the site.

Not another full-screen menu.

Not Algolia.

Not Elasticsearch.

And definitely not a fake desktop operating system.

Just a native <dialog>, a searchable array, a little relevance scoring, keyboard navigation, and ordinary vanilla JavaScript.

The result sits somewhere between:

site search
+
navigation
+
quick actions
+
keyboard launcher

And for a sufficiently large site, I think that combination is much more interesting than another fancy menu.

The Interesting Part of RI/OS Wasn’t the OS #

The idea came from looking through recent experimental work.

On August 21, Awwwards featured RI/OS, Roberto Izquierdo’s portfolio built as an interactive desktop operating system. It includes applications, a dock, terminal, Easter eggs, and—most relevant to this experiment—a Spotlight-style search interface.

Source:

https://www.awwwards.com/sites/ri-os

RI/OS takes the metaphor very far.

I wouldn’t.

For a commercial project, I am more interested in extracting one useful interaction from the metaphor than turning the client’s website into macOS.

The useful part is this:

⌘ K
 ↓
What do you need?
 ↓
destination or action

A few other things appeared on my radar around the same time. Minimal Gallery added ScreenTune, CSS Design Awards nominated 404 Damned with a brutalist/WebGL direction, and Codrops published a substantial engineering breakdown of a Three.js laboratory involving a game, multiple fly-through scenes, GLTF assets and CRT displays.

Those experiments are interesting precisely because today’s component goes in the opposite technical direction.

No rendering engine.

No 3D scene.

No animation framework.

The interaction is mostly information architecture.

Search and Command Palettes Solve Different Problems #

At first glance this looks like search with a keyboard shortcut.

I don’t think it is.

Traditional Site Search Command Palette (⌘K)
Primarily finds content Finds destinations and actions
Usually searches page/post text Can search curated aliases and intents
Often opens a result page Can execute the goal immediately
Usually depends on an index/backend Small sites can run entirely locally
Behaves like “Google inside this website” Behaves more like an application interface
Searches what the site calls something Can search what the user calls it

That last row is the important one.

Suppose the service is called:

Performance Optimization

A visitor may type:

speed

and immediately get:

⚡ Performance Optimization
Core Web Vitals and site speed

Or they type:

acf

and get:

</> WordPress Website Development
PHP, ACF and custom development

The visitor doesn’t need to know what category I placed the page under.

They express intent.

The interface translates that intent into my site’s architecture.

The Component #

The palette looks roughly like this:

⌘ K
 ↓
┌────────────────────────────────────┐
│ 🔎 What are you looking for?       │
├────────────────────────────────────┤
│ ↗ Contact                          │
│ ◫ Projects                         │
│ </> WordPress Development          │
│ ⚡ Performance                     │
│ ◎ WhatsApp                         │
│ ◐ Switch theme                     │
└────────────────────────────────────┘

Through one input, a user can:

  • navigate to a page or project;
  • find a service through aliases;
  • open a contact section;
  • start WhatsApp;
  • execute a JavaScript action;
  • change the site theme;
  • search dynamically generated WordPress content.

The implementation in my demo uses Hebrew interface content deliberately, because it also makes the component deal with a real multilingual/RTL interface rather than a perfectly convenient English-only demo.

HTML #

The markup starts with an ordinary header trigger and a native dialog.

<header class="site-header" dir="rtl">

    <a href="/" class="site-logo">
        AKAVA
    </a>

    <nav class="site-nav" aria-label="ניווט ראשי">
        <a href="/projects/">פרויקטים</a>
        <a href="/services/">שירותים</a>
        <a href="/about/">אודות</a>
    </nav>

    <button
        type="button"
        class="command-trigger"
        data-command-open
        aria-haspopup="dialog"
    >
        <span
            class="command-trigger__icon"
            aria-hidden="true"
        >
            ⌕
        </span>

        <span>חיפוש</span>

        <kbd>⌘K</kbd>
    </button>

</header>

<dialog
    class="command-palette"
    data-command
    aria-labelledby="command-title"
    dir="rtl"
>
    <div class="command-palette__panel">

        <div class="command-palette__search">

            <span
                class="command-palette__search-icon"
                aria-hidden="true"
            >
                ⌕
            </span>

            <label
                id="command-title"
                class="sr-only"
                for="site-command-input"
            >
                חיפוש באתר ופעולות מהירות
            </label>

            <input
                id="site-command-input"
                type="search"
                class="command-palette__input"
                data-command-input
                placeholder="מה אתם מחפשים?"
                autocomplete="off"
                spellcheck="false"
                aria-controls="command-results"
                aria-autocomplete="list"
            >

            <button
                type="button"
                class="command-palette__close"
                data-command-close
                aria-label="סגירת החיפוש"
            >
                ESC
            </button>

        </div>

        <div
            id="command-results"
            class="command-palette__results"
            data-command-results
            role="listbox"
            aria-label="תוצאות חיפוש"
        ></div>

        <footer class="command-palette__footer">
            <span>
                <kbd>↑</kbd>
                <kbd>↓</kbd>
                ניווט
            </span>

            <span>
                <kbd>↵</kbd>
                בחירה
            </span>

            <span>
                <kbd>esc</kbd>
                סגירה
            </span>
        </footer>

    </div>
</dialog>

One thing I would preserve from the beginning is the accessibility structure.

Don’t build the visual component first and then go hunting for places to insert semantics later.

The dialog, label, listbox relationship, buttons, keyboard model and reduced-motion behavior are much easier to reason about while the component architecture is still being built.

CSS #

The styling is intentionally closer to a product interface than a decorative search overlay.

:root {
    --command-bg: #0f1115;
    --command-panel: #15181d;
    --command-item: #1b1f25;
    --command-item-active: #252b33;
    --command-text: #f3f4f6;
    --command-muted: #8b929d;
    --command-line: rgba(255, 255, 255, .09);
    --command-accent: #d7ff43;
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

.site-header {
    display: flex;
    align-items: center;
    gap: 30px;

    min-height: 76px;
    padding-inline: clamp(20px, 5vw, 70px);

    border-bottom: 1px solid rgba(0, 0, 0, .1);
}

.site-logo {
    margin-inline-end: auto;

    color: #111;
    text-decoration: none;

    font-weight: 700;
    letter-spacing: -.04em;
}

.site-nav {
    display: flex;
    gap: 24px;
}

.site-nav a {
    color: #4d5157;
    text-decoration: none;
    font-size: 14px;
}

.command-trigger {
    display: inline-flex;
    align-items: center;
    gap: 10px;

    min-height: 42px;
    padding: 0 12px;

    border: 1px solid rgba(0, 0, 0, .12);
    border-radius: 9px;

    background: #fff;
    color: #222;

    cursor: pointer;
}

.command-trigger__icon {
    font-size: 18px;
}

.command-trigger kbd {
    padding: 3px 6px;

    border: 1px solid rgba(0, 0, 0, .12);
    border-radius: 5px;

    background: #f3f3ef;

    font: inherit;
    font-size: 11px;
    color: #777;
}


/* Dialog */

.command-palette {
    width: min(680px, calc(100vw - 30px));
    max-width: none;

    padding: 0;

    border: 1px solid var(--command-line);
    border-radius: 16px;

    background: var(--command-panel);
    color: var(--command-text);

    box-shadow:
        0 35px 100px rgba(0, 0, 0, .45);

    overflow: hidden;
}

.command-palette::backdrop {
    background: rgba(5, 7, 10, .72);
    backdrop-filter: blur(8px);
}

.command-palette__panel {
    overflow: hidden;
}

.command-palette__search {
    display: grid;
    grid-template-columns: auto 1fr auto;
    gap: 14px;
    align-items: center;

    min-height: 72px;
    padding-inline: 18px;

    border-bottom: 1px solid var(--command-line);
}

.command-palette__search-icon {
    color: var(--command-muted);
    font-size: 23px;
}

.command-palette__input {
    width: 100%;
    min-width: 0;

    padding: 0;

    border: 0;
    outline: 0;

    background: transparent;
    color: var(--command-text);

    font: inherit;
    font-size: 18px;
}

.command-palette__input::placeholder {
    color: #737b87;
}

.command-palette__close {
    padding: 5px 7px;

    border: 1px solid var(--command-line);
    border-radius: 6px;

    background: transparent;
    color: var(--command-muted);

    font-size: 10px;

    cursor: pointer;
}

.command-palette__results {
    max-height: min(480px, 60vh);

    padding: 10px;

    overflow-y: auto;
    overscroll-behavior: contain;
}

.command-group + .command-group {
    margin-top: 10px;
}

.command-group__title {
    display: block;

    padding: 10px 12px 7px;

    font-size: 10px;
    letter-spacing: .08em;
    text-transform: uppercase;

    color: var(--command-muted);
}

.command-item {
    display: grid;
    grid-template-columns: 40px 1fr auto;
    gap: 12px;
    align-items: center;

    width: 100%;
    min-height: 64px;

    padding: 10px 12px;

    border: 0;
    border-radius: 9px;

    background: transparent;
    color: inherit;

    text-align: start;

    cursor: pointer;
}

.command-item:hover,
.command-item.is-active {
    background: var(--command-item-active);
}

.command-item__icon {
    display: grid;
    place-items: center;

    width: 36px;
    aspect-ratio: 1;

    border: 1px solid var(--command-line);
    border-radius: 8px;

    color: var(--command-accent);
}

.command-item__content {
    min-width: 0;
}

.command-item__title,
.command-item__description {
    display: block;
}

.command-item__title {
    margin-bottom: 3px;

    font-size: 14px;
    font-weight: 600;
}

.command-item__description {
    overflow: hidden;

    color: var(--command-muted);

    font-size: 12px;

    white-space: nowrap;
    text-overflow: ellipsis;
}

.command-item__shortcut {
    color: #69717d;
    font-size: 12px;
}

.command-empty {
    display: grid;
    place-items: center;

    min-height: 180px;

    text-align: center;
    color: var(--command-muted);
}

.command-empty strong {
    display: block;

    margin-bottom: 5px;

    color: var(--command-text);
}

.command-palette__footer {
    display: flex;
    gap: 18px;

    padding: 11px 18px;

    border-top: 1px solid var(--command-line);

    color: var(--command-muted);

    font-size: 10px;
}

.command-palette__footer span {
    display: flex;
    align-items: center;
    gap: 5px;
}

.command-palette__footer kbd {
    font: inherit;
    color: #c1c5cc;
}


/* Visually hidden but available to assistive tech */

.sr-only {
    position: absolute;

    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;

    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;

    border: 0;
}


/* Focus */

.command-palette button:focus-visible,
.command-trigger:focus-visible {
    outline: 2px solid var(--command-accent);
    outline-offset: 2px;
}


/* Mobile */

@media (max-width: 700px) {

    .site-nav {
        display: none;
    }

    .command-trigger > span:not(.command-trigger__icon) {
        display: none;
    }

    .command-palette {
        width: calc(100vw - 16px);
        margin-top: 8px;
    }

    .command-palette__results {
        max-height: 58vh;
    }

    .command-palette__footer {
        display: none;
    }

}


/* Reduced motion */

@media (prefers-reduced-motion: reduce) {

    .command-palette *,
    .command-trigger {
        scroll-behavior: auto;
    }

}

JavaScript #

The search itself is intentionally small.

Each command has a title, description, keywords and either a URL or an action.

<script>
(() => {

    const dialog = document.querySelector('[data-command]');

    if (!dialog) {
        return;
    }

    const input = dialog.querySelector('[data-command-input]');
    const results = dialog.querySelector('[data-command-results]');
    const closeButton = dialog.querySelector('[data-command-close]');
    const openButtons = document.querySelectorAll('[data-command-open]');

    let activeIndex = 0;
    let visibleItems = [];


    /**
     * Central command registry.
     *
     * This can later be generated by WordPress.
     */
    const commands = [

        {
            group: 'ניווט',
            title: 'דף הבית',
            description: 'חזרה לעמוד הראשי',
            icon: '⌂',
            keywords: [
                'home',
                'ראשי',
                'בית'
            ],
            url: '/'
        },

        {
            group: 'ניווט',
            title: 'פרויקטים',
            description: 'עבודות ופרויקטים נבחרים',
            icon: '◫',
            keywords: [
                'projects',
                'portfolio',
                'עבודות',
                'תיק'
            ],
            url: '/projects/'
        },

        {
            group: 'שירותים',
            title: 'פיתוח אתרי WordPress',
            description: 'PHP, ACF ופיתוח מותאם אישית',
            icon: '</>',
            keywords: [
                'wordpress',
                'wp',
                'php',
                'acf',
                'development',
                'dev',
                'פיתוח'
            ],
            url: '/services/wordpress/'
        },

        {
            group: 'שירותים',
            title: 'שיפור ביצועים',
            description: 'Core Web Vitals ומהירות האתר',
            icon: '⚡',
            keywords: [
                'performance',
                'speed',
                'pagespeed',
                'core web vitals',
                'מהירות',
                'ביצועים'
            ],
            url: '/services/performance/'
        },

        {
            group: 'פעולות',
            title: 'יצירת קשר',
            description: 'פתיחת טופס יצירת קשר',
            icon: '✉',
            keywords: [
                'contact',
                'form',
                'lead',
                'צור קשר',
                'פנייה'
            ],

            /**
             * Commands may execute actions instead of navigation.
             */
            action() {

                const contact = document.querySelector('#contact');

                if (!contact) {
                    window.location.href = '/contact/';
                    return;
                }

                dialog.close();

                contact.scrollIntoView({
                    behavior: getScrollBehavior(),
                    block: 'start'
                });

            }
        },

        {
            group: 'פעולות',
            title: 'WhatsApp',
            description: 'פתיחת שיחה מהירה',
            icon: '◎',
            keywords: [
                'whatsapp',
                'וואטסאפ',
                'chat',
                'message'
            ],
            url: 'https://wa.me/972501234567',
            external: true
        },

        {
            group: 'פעולות',
            title: 'החלפת ערכת צבעים',
            description: 'מעבר בין מצב בהיר וכהה',
            icon: '◐',
            keywords: [
                'theme',
                'dark',
                'light',
                'mode',
                'צבע',
                'כהה',
                'בהיר'
            ],

            action() {

                document.documentElement.classList.toggle(
                    'dark-mode'
                );

                dialog.close();

            }
        }

    ];


    /**
     * Normalize user input for predictable searching.
     */
    function normalize(value) {

        return value
            .toLocaleLowerCase()
            .trim()
            .replace(/\s+/g, ' ');

    }


    /**
     * Calculate a lightweight relevance score.
     */
    function getScore(command, query) {

        if (!query) {
            return 1;
        }

        const title = normalize(command.title);
        const description = normalize(
            command.description || ''
        );

        const keywords = command.keywords
            .map(normalize);

        let score = 0;


        /**
         * Exact title match wins.
         */
        if (title === query) {
            score += 100;
        }


        /**
         * Beginning of title is highly relevant.
         */
        if (title.startsWith(query)) {
            score += 60;
        }


        /**
         * Normal substring in title.
         */
        if (title.includes(query)) {
            score += 40;
        }


        /**
         * Description carries less weight.
         */
        if (description.includes(query)) {
            score += 15;
        }


        /**
         * Keywords make aliases possible.
         */
        keywords.forEach(keyword => {

            if (keyword === query) {
                score += 50;
            } else if (keyword.startsWith(query)) {
                score += 25;
            } else if (keyword.includes(query)) {
                score += 10;
            }

        });


        /**
         * Allow multi-word queries where every word appears
         * somewhere in the searchable command text.
         */
        const words = query.split(' ');

        const haystack = [
            title,
            description,
            ...keywords
        ].join(' ');

        if (
            words.length > 1 &&
            words.every(word => haystack.includes(word))
        ) {
            score += 20;
        }

        return score;

    }


    /**
     * Escape dynamic text before rendering HTML.
     */
    function escapeHTML(value) {

        return String(value)
            .replaceAll('&', '&amp;')
            .replaceAll('<', '&lt;')
            .replaceAll('>', '&gt;')
            .replaceAll('"', '&quot;')
            .replaceAll("'", '&#039;');

    }


    /**
     * Search, rank and render commands.
     */
    function render(query = '') {

        const normalizedQuery = normalize(query);

        visibleItems = commands
            .map(command => ({
                command,
                score: getScore(
                    command,
                    normalizedQuery
                )
            }))
            .filter(item => item.score > 0)
            .sort((a, b) => b.score - a.score)
            .map(item => item.command);

        activeIndex = 0;


        if (!visibleItems.length) {

            results.innerHTML = `
                <div class="command-empty">
                    <div>
                        <strong>לא מצאנו תוצאה</strong>
                        נסו ביטוי אחר
                    </div>
                </div>
            `;

            return;

        }


        /**
         * Group results while keeping relevance order
         * inside each group.
         */
        const groups = new Map();

        visibleItems.forEach(command => {

            if (!groups.has(command.group)) {
                groups.set(command.group, []);
            }

            groups.get(command.group).push(command);

        });


        let globalIndex = 0;

        results.innerHTML = [
            ...groups.entries()
        ].map(([group, items]) => {

            const itemMarkup = items.map(command => {

                const index = globalIndex++;

                return `
                    <button
                        type="button"
                        class="command-item${index === 0 ? ' is-active' : ''}"
                        data-command-index="${index}"
                        role="option"
                        aria-selected="${index === 0 ? 'true' : 'false'}"
                    >

                        <span
                            class="command-item__icon"
                            aria-hidden="true"
                        >
                            ${escapeHTML(command.icon)}
                        </span>

                        <span class="command-item__content">

                            <span class="command-item__title">
                                ${escapeHTML(command.title)}
                            </span>

                            <span class="command-item__description">
                                ${escapeHTML(command.description)}
                            </span>

                        </span>

                        <span class="command-item__shortcut">
                            ↵
                        </span>

                    </button>
                `;

            }).join('');


            return `
                <section class="command-group">

                    <span class="command-group__title">
                        ${escapeHTML(group)}
                    </span>

                    ${itemMarkup}

                </section>
            `;

        }).join('');

    }


    /**
     * Update keyboard selection without rebuilding the list.
     */
    function setActive(index) {

        const items = [
            ...results.querySelectorAll(
                '[data-command-index]'
            )
        ];

        if (!items.length) {
            return;
        }

        activeIndex = (
            index + items.length
        ) % items.length;

        items.forEach((item, itemIndex) => {

            const active = itemIndex === activeIndex;

            item.classList.toggle(
                'is-active',
                active
            );

            item.setAttribute(
                'aria-selected',
                active ? 'true' : 'false'
            );

        });

        items[activeIndex].scrollIntoView({
            block: 'nearest'
        });

    }


    /**
     * Execute either a custom action or navigation command.
     */
    function execute(command) {

        if (!command) {
            return;
        }

        if (
            typeof command.action === 'function'
        ) {

            command.action();
            return;

        }

        if (!command.url) {
            return;
        }

        if (command.external) {

            window.open(
                command.url,
                '_blank',
                'noopener,noreferrer'
            );

            dialog.close();

            return;

        }

        window.location.href = command.url;

    }


    /**
     * Respect reduced-motion preferences.
     */
    function getScrollBehavior() {

        return window.matchMedia(
            '(prefers-reduced-motion: reduce)'
        ).matches
            ? 'auto'
            : 'smooth';

    }


    function openCommand() {

        if (dialog.open) {
            return;
        }

        render('');

        dialog.showModal();

        requestAnimationFrame(() => {
            input.focus();
        });

    }


    function closeCommand() {

        dialog.close();

        input.value = '';

    }


    /**
     * Search while typing.
     */
    input.addEventListener('input', () => {

        render(input.value);

    });


    /**
     * Keyboard navigation inside the palette.
     */
    input.addEventListener('keydown', event => {

        if (event.key === 'ArrowDown') {

            event.preventDefault();

            setActive(activeIndex + 1);

        }

        if (event.key === 'ArrowUp') {

            event.preventDefault();

            setActive(activeIndex - 1);

        }

        if (event.key === 'Enter') {

            event.preventDefault();

            execute(
                visibleItems[activeIndex]
            );

        }

    });


    /**
     * Mouse / pointer selection.
     */
    results.addEventListener('click', event => {

        const item = event.target.closest(
            '[data-command-index]'
        );

        if (!item) {
            return;
        }

        execute(
            visibleItems[
                Number(item.dataset.commandIndex)
            ]
        );

    });


    results.addEventListener(
        'pointermove',
        event => {

            const item = event.target.closest(
                '[data-command-index]'
            );

            if (!item) {
                return;
            }

            setActive(
                Number(item.dataset.commandIndex)
            );

        }
    );


    /**
     * Global Cmd/Ctrl + K shortcut.
     */
    document.addEventListener('keydown', event => {

        const commandShortcut =
            (event.metaKey || event.ctrlKey) &&
            event.key.toLowerCase() === 'k';

        if (!commandShortcut) {
            return;
        }

        event.preventDefault();

        if (dialog.open) {
            closeCommand();
        } else {
            openCommand();
        }

    });


    openButtons.forEach(button => {

        button.addEventListener(
            'click',
            openCommand
        );

    });


    closeButton.addEventListener(
        'click',
        closeCommand
    );


    /**
     * Native dialog Escape handling requires no custom focus trap.
     */
    dialog.addEventListener('close', () => {

        input.value = '';

    });

})();
</script>

Search by Intent, Not Only Page Titles #

The little scoring function is what turns this from simple filtering into something more useful.

An exact title match gets:

score += 100;

A title beginning with the query:

score += 60;

A normal title substring:

score += 40;

But aliases also participate:

keywords: [
    'wordpress',
    'wp',
    'php',
    'acf',
    'development',
    'dev',
    'פיתוח'
]

So these queries:

WordPress
wp
acf
php
dev
פיתוח

can all point toward the same destination.

This is why I increasingly think of the component as an intent interface, not just search.

The URL hierarchy can remain clean and editorially sensible.

The command layer can speak the messier language actual humans use.

Live CodePen #

The expanded implementation is available here (demo uses English for the interface):

Try searching for:

speed
acf
wordpress
projects
whatsapp
theme

The interesting part is that some results navigate while others execute actions.

Accessibility Is Part of the Architecture #

There are several details here that are easy to throw away when building a visual prototype.

I would keep them.

Why dialog.showModal() instead of a homemade overlay? #

The palette uses:

dialog.showModal();

rather than toggling something like:

<div class="overlay">

A modal <dialog> gives the browser much more information about what the interface actually is.

When shown modally, the rest of the document is made inert for interaction purposes, focus is constrained to the modal interaction, and Escape has native closing behavior.

Reference:

https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog

That means I don’t need to begin the component by writing my own focus trap.

What about keyboard focus? #

After opening, I deliberately focus the search field:

requestAnimationFrame(() => {
    input.focus();
});

Now the keyboard workflow is immediate:

⌘K
type
↓
↓
Enter

No pointer required.

Why role="listbox" and aria-selected? #

The results container identifies itself as:

role="listbox"

and each selectable result is exposed as:

role="option"
aria-selected="true"

When the keyboard selection changes, JavaScript updates both the visual .is-active state and aria-selected.

That is important.

A highlighted row cannot exist only as a background-color change if we want assistive technology to understand the same selection state.

For a production implementation, I would test the final interaction with actual screen readers rather than treating ARIA attributes as a certification sticker.

Reduced motion is already part of the component #

The helper:

function getScrollBehavior() {

    return window.matchMedia(
        '(prefers-reduced-motion: reduce)'
    ).matches
        ? 'auto'
        : 'smooth';

}

means an action that scrolls to the contact section doesn’t force smooth motion on somebody who has requested reduced motion.

Again, this is much easier to design now than bolt on later.

Accessibility attributes and interaction behavior are better laid down while producing the component than retrofitted after the UI has already hardened around assumptions.

WordPress Changes the Equation #

The hard-coded array is useful for a CodePen.

On a large WordPress site, I would not maintain it manually.

WordPress can generate the navigation commands from real content:

<?php

$command_pages = get_posts([
    'post_type' => [
        'page',
        'service',
        'project',
    ],
    'post_status'    => 'publish',
    'posts_per_page' => 100,
    'orderby'        => 'menu_order title',
    'order'          => 'ASC',
]);

$commands = [];

foreach ($command_pages as $item) {

    $commands[] = [
        'group'       => get_post_type_object(
            $item->post_type
        )->labels->singular_name,

        'title'       => get_the_title($item),

        'description' => get_field(
            'short_description',
            $item->ID
        ) ?: '',

        'url'         => get_permalink($item),

        'keywords'    => array_values(
            array_filter([
                get_post_meta(
                    $item->ID,
                    'command_keywords',
                    true
                ),

                $item->post_name,
            ])
        ),
    ];

}

?>

<script
    type="application/json"
    id="site-command-data"
>
<?= wp_json_encode(
    $commands,
    JSON_UNESCAPED_UNICODE |
    JSON_UNESCAPED_SLASHES
); ?>
</script>

Then JavaScript becomes:

const commands = JSON.parse(
    document.querySelector(
        '#site-command-data'
    ).textContent
);

Now publishing a new service or project can automatically make it searchable through ⌘K.

The command palette stops being a separate content-management problem.

ACF Can Make Search Smarter #

I would take this one step further and give relevant WordPress content an ACF group such as:

command_palette enabled title keywords icon priority

Imagine the real page title is:

Digital Solutions for Businesses

That may be the correct marketing title.

But users might look for it using:

wordpress site development website developer

Those terms don’t need to be awkwardly stuffed into visible copy just to make the command interface work.

They can be intentional search aliases.

This gives the site a small layer of controlled synonym mapping without immediately requiring Algolia, Elasticsearch or an AI search API.

When Local Search Stops Making Sense #

For a site with perhaps a couple hundred commandable destinations, I would keep this architecture simple.

The browser can filter a small in-memory array very quickly.

But imagine:

10,000 products
4,000 articles
600 pages

I would not dump all of that into the HTML.

At that point:

⌘K
 ↓
type 2–3 characters
 ↓
debounce ~150 ms
 ↓
WordPress REST endpoint
 ↓
top 10 results

Static commands can remain local:

WhatsApp
Contact
Theme
Account
Cart

while content search becomes remote.

So opening the palette is still instant, and actions remain instant, but the site does not ship thousands of records on every page.

A Tiny Discovery Improvement #

There is another problem with command palettes:

people have to discover what they can type.

A button that permanently says:

Search

doesn’t teach much.

I would experiment with periodically changing an idle hint:

Search "projects"

then:

Search "WordPress"

then:

Search "speed"

Not every second.

No slot-machine animation.

Just a quiet change after several seconds of inactivity.

It communicates something important:

you do not need to know the exact page title.

I would also stop rotating once the user interacts with the control and respect reduced-motion preferences if the change itself is animated.

Commands That Are Not Pages #

This is where the component becomes much more interesting than search.

There is no reason every command needs a URL.

It can expose actions:

Call us
→ tel:

Open WhatsApp
→ wa.me

Go to cart
→ WooCommerce cart

Sign in
→ account

Print page
→ window.print()

Share
→ navigator.share()

Dark mode
→ CSS theme

Accessibility
→ accessibility controls

The command palette becomes a second interface over the site.

The normal navigation still exists.

The normal pages still exist.

But experienced users get a shortcut directly to intentions.

RI/OS takes that idea all the way to an operating-system metaphor. For most commercial work, I would stop much earlier.

I don’t need the whole OS.

I want the useful part.

Who Actually Needs This? #

The answer is not “every website.”

A five-page brochure site probably does not need ⌘K.

But several categories make much more sense.

Agencies with too many services #

The problem:

Strategy
Brand
UX
UI
Development
WordPress
Shopify
SEO
Performance
Automation
AI
Analytics
...

Eventually the navigation becomes taxonomy homework.

Useful commands might be:

"acf"
→ WordPress Development

"speed"
→ Performance Optimization

The user can bypass the agency’s internal service taxonomy.

SaaS products #

A SaaS user often does not want to “browse the website.”

They want to do something.

Commands could include:

"billing"
→ Manage subscription

"api"
→ API documentation

"status"
→ System status

This is where the command-palette metaphor feels especially natural because the website already behaves partly like an application.

WooCommerce stores #

Search normally concentrates on products.

A palette can mix products with actions:

"cart"
→ Open cart

"orders"
→ My account / orders

"running shoes"
→ matching products

You could also expose recently viewed products or categories.

Corporate portals #

Employees often aren’t looking for “pages” at all.

They are looking for:

vacation form
IT contact
expense policy
brand assets
purchase request

Commands such as:

"vacation"
→ Leave request form

"IT"
→ Help desk / contact

can be substantially faster than asking employees to remember which department owns which resource.

Documentation sites #

Documentation is another strong candidate.

Commands can mix:

API endpoint
guide
component
changelog
GitHub
copy command

At that point the palette starts functioning as a compact developer tool inside the documentation itself.

A Few Other Things on My Technical Radar #

The Web Standards feed has also been useful recently for a different category of ideas: emerging CSS features such as @function, larger clickable-card techniques involving Anchor Positioning, newer theming primitives such as light-dark() and contrast-color(), and accessibility questions around new layout capabilities.

Source:

https://t.me/s/webstandards_ru/

These are less spectacular than award-gallery WebGL experiments.

They are often more predictive of what ends up in ordinary production CSS a year later.

And that is probably why this particular experiment appealed to me.

It borrows the Spotlight idea from a much more elaborate site, strips away the operating-system theatre, and leaves a component I can actually reuse:

Site Command Palette / Cmd+K.

Frequently Asked Questions #

What is a website command palette? #

A website command palette is a keyboard-accessible interface that lets users search destinations and execute actions from one input. It commonly opens with Cmd+K or Ctrl+K.

Is a command palette the same as site search? #

No.

Site search primarily retrieves content. A command palette can combine content retrieval, navigation aliases and executable actions such as opening a cart, switching themes or starting a contact flow.

Can I build Cmd+K search without React? #

Yes.

The implementation above uses native HTML <dialog>, CSS and vanilla JavaScript. A framework is not required.

Do I need Algolia or Elasticsearch? #

Not for a small command set.

A modest list of pages, services and actions can be searched locally. Larger content collections are better queried through a backend or search endpoint.

How do I add Cmd+K search to WordPress? #

WordPress can generate a JSON array from published pages, custom post types or ACF fields. JavaScript can then search that data locally, or query a custom REST endpoint for larger sites.

Is a command palette accessible? #

It can be, but keyboard accessibility is not automatic simply because the interface responds to Cmd+K.

Use semantic controls, a correctly implemented modal dialog, clear labels, visible focus, meaningful selection states, sensible keyboard navigation and reduced-motion support. Test the finished component with assistive technology.

Should Cmd+K replace the main navigation? #

Usually not.

I treat it as an alternative navigation layer, not the only navigation system. Links and menus remain discoverable and robust; the palette gives users a faster route when they know roughly what they want.

Should mobile users get the command palette? #

They can, but the visible trigger becomes more important because mobile users do not have Cmd+K.

The same searchable dialog can work well on touch devices if its layout, input behavior and result targets are designed responsively.

Conclusion #

I started this experiment because I liked the Spotlight-style navigation inside RI/OS.

What I ended up liking more was the idea underneath it:

a website does not have to force every visitor through its navigation hierarchy.

A user can simply say:

speed

or:

acf

or:

cart

and the interface can translate that intent into a destination or action.

That makes ⌘K interesting to me for reasons that have very little to do with the keyboard shortcut itself.

It is a thin translation layer between:

what the user calls something

and:

how the website is organized

For a small website, that may be unnecessary.

For an agency with dozens of services, a SaaS product, documentation site, corporate portal, large WordPress installation or WooCommerce store, it can become a genuinely useful second navigation system.

And the first version does not need a search platform, framework or new front-end engine.

A dialog.

An array.

A scoring function.

Some careful accessibility work.

And about as much JavaScript as the problem actually deserves.