16 CSS Mobile Navigation Patterns 15 / 16

Command Palette Search Nav

A Spotlight-style command palette triggered by a search bar click or a keyboard shortcut.

CSS + JS MIT licensed
Live Demo Open in tab

This is a full-page demo — interact inside the frame above, or open it in the playground for the full-screen experience.

Open in playground

The code

<div class="mn-15" id="mn-15-root">
  <div class="mn-15__trigger" id="mn-15-trigger">
    <span class="mn-15__trigger-icon">🔍</span>
    <span class="mn-15__trigger-text">Search or jump to...</span>
    <div class="mn-15__trigger-kbd"><kbd>⌘</kbd><kbd>K</kbd></div>
  </div>

  <div class="mn-15__palette" id="mn-15-palette">
    <div class="mn-15__palette-box">
      <div class="mn-15__input-row">
        <span class="mn-15__input-icon">🔍</span>
        <input class="mn-15__input" id="mn-15-input" placeholder="Search commands, pages, people...">
        <button class="mn-15__input-clear" id="mn-15-clear">✕</button>
      </div>
      <div class="mn-15__results" id="mn-15-results">
        <!-- Populated by JS -->
      </div>
      <div class="mn-15__palette-footer">
        <span><kbd>↑↓</kbd> Navigate</span>
        <span><kbd>↵</kbd> Open</span>
        <span><kbd>Esc</kbd> Close</span>
      </div>
    </div>
  </div>

  <div class="mn-15__page">
    <div class="mn-15__section-title">Quick Actions</div>
    <div class="mn-15__quick-actions">
      <div class="mn-15__action-card">
        <div class="icon">✍️</div>
        <h4>New Document</h4>
        <p>Create blank doc</p>
      </div>
      <div class="mn-15__action-card">
        <div class="icon">📅</div>
        <h4>Schedule</h4>
        <p>Add to calendar</p>
      </div>
      <div class="mn-15__action-card">
        <div class="icon">👥</div>
        <h4>Invite Team</h4>
        <p>Share workspace</p>
      </div>
      <div class="mn-15__action-card">
        <div class="icon">📊</div>
        <h4>Analytics</h4>
        <p>View insights</p>
      </div>
    </div>
    <div class="mn-15__section-title">Recently Visited</div>
    <div class="mn-15__recent-item">
      <div class="mn-15__recent-icon">📄</div>
      <div class="mn-15__recent-text"><h4>Product Roadmap</h4><p>Updated 10 min ago</p></div>
    </div>
    <div class="mn-15__recent-item">
      <div class="mn-15__recent-icon">💬</div>
      <div class="mn-15__recent-text"><h4>Design Review Thread</h4><p>Updated 1 hour ago</p></div>
    </div>
    <div class="mn-15__recent-item">
      <div class="mn-15__recent-icon">📋</div>
      <div class="mn-15__recent-text"><h4>Sprint Planning Board</h4><p>Updated yesterday</p></div>
    </div>
  </div>
</div>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0f0f13; font-family: 'Segoe UI', sans-serif; }

.mn-15 {
  --bg: #111113;
  --surface: #1c1c1f;
  --surface2: #252528;
  --border: #2d2d32;
  --accent: #f97316;
  --text: #ececf1;
  --muted: #737379;
  --highlight: rgba(249,115,22,0.1);
  width: 375px;
  height: 667px;
  position: relative;
  overflow: hidden;
  background: var(--bg);
  border-radius: 32px;
  box-shadow: 0 30px 80px rgba(0,0,0,0.8);
}

/* Search trigger button */
.mn-15__trigger {
  position: absolute;
  top: 20px;
  left: 16px;
  right: 16px;
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 12px 14px;
  cursor: text;
  z-index: 5;
  transition: border-color 0.2s, box-shadow 0.2s;
}
.mn-15__trigger:hover {
  border-color: rgba(249,115,22,0.4);
  box-shadow: 0 0 0 3px rgba(249,115,22,0.08);
}
.mn-15__trigger-icon { font-size: 16px; color: var(--muted); }
.mn-15__trigger-text { flex: 1; color: var(--muted); font-size: 14px; }
.mn-15__trigger-kbd {
  display: flex;
  gap: 4px;
}
.mn-15__trigger-kbd kbd {
  background: var(--surface2);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 2px 6px;
  font-size: 11px;
  color: var(--muted);
  font-family: inherit;
}

/* Command palette overlay */
.mn-15__palette {
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.6);
  z-index: 20;
  display: flex;
  flex-direction: column;
  padding: 60px 16px 16px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
}
.mn-15__palette.is-open {
  opacity: 1;
  pointer-events: all;
}

.mn-15__palette-box {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 64px rgba(0,0,0,0.5);
  transform: scale(0.95) translateY(-8px);
  transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mn-15__palette.is-open .mn-15__palette-box {
  transform: scale(1) translateY(0);
}

/* Search input */
.mn-15__input-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 16px;
  border-bottom: 1px solid var(--border);
}
.mn-15__input-icon { font-size: 18px; color: var(--muted); }
.mn-15__input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  font-size: 15px;
  color: var(--text);
  font-family: inherit;
}
.mn-15__input::placeholder { color: var(--muted); }
.mn-15__input-clear {
  width: 22px; height: 22px;
  border-radius: 50%;
  background: var(--surface2);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
  font-size: 10px;
  color: var(--muted);
  border: none;
  padding: 0;
  display: none;
}
.mn-15__input-clear.is-visible { display: flex; }

/* Results */
.mn-15__results { max-height: 380px; overflow-y: auto; }
.mn-15__group-label {
  padding: 8px 16px 4px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--muted);
}
.mn-15__result-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  cursor: pointer;
  transition: background 0.1s;
}
.mn-15__result-item:hover, .mn-15__result-item.is-selected {
  background: var(--highlight);
}
.mn-15__result-icon {
  width: 32px; height: 32px;
  border-radius: 8px;
  display: flex; align-items: center; justify-content: center;
  font-size: 16px;
  flex-shrink: 0;
  background: var(--surface2);
}
.mn-15__result-info { flex: 1; }
.mn-15__result-info h4 { font-size: 13px; font-weight: 500; color: var(--text); }
.mn-15__result-info p { font-size: 11px; color: var(--muted); }
.mn-15__result-shortcut {
  font-size: 11px;
  color: var(--muted);
}
.mn-15__result-shortcut kbd {
  background: var(--surface2);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 5px;
  font-family: inherit;
}
.mn-15__divider { height: 1px; background: var(--border); margin: 4px 0; }

/* Palette footer */
.mn-15__palette-footer {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 8px 14px;
  border-top: 1px solid var(--border);
}
.mn-15__palette-footer span {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 11px;
  color: var(--muted);
}
.mn-15__palette-footer kbd {
  background: var(--surface2);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 5px;
  font-size: 10px;
  font-family: inherit;
}

/* Page content */
.mn-15__page {
  position: absolute;
  top: 72px; left: 0; right: 0; bottom: 0;
  padding: 16px;
  overflow-y: auto;
}
.mn-15__section-title {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 12px;
}
.mn-15__quick-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin-bottom: 20px;
}
.mn-15__action-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 16px;
  cursor: pointer;
  transition: border-color 0.2s;
}
.mn-15__action-card:hover { border-color: var(--accent); }
.mn-15__action-card .icon { font-size: 24px; margin-bottom: 8px; }
.mn-15__action-card h4 { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
.mn-15__action-card p { font-size: 11px; color: var(--muted); }
.mn-15__recent-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid rgba(255,255,255,0.04);
  cursor: pointer;
}
.mn-15__recent-icon {
  width: 32px; height: 32px;
  border-radius: 8px;
  background: var(--surface);
  display: flex; align-items: center; justify-content: center;
  font-size: 15px;
}
.mn-15__recent-text h4 { font-size: 13px; font-weight: 500; color: var(--text); }
.mn-15__recent-text p { font-size: 11px; color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  .mn-15__palette, .mn-15__palette-box { transition: none; }
}
(function() {
  const trigger = document.getElementById('mn-15-trigger');
  const palette = document.getElementById('mn-15-palette');
  const input = document.getElementById('mn-15-input');
  const results = document.getElementById('mn-15-results');
  const clear = document.getElementById('mn-15-clear');
  const root = document.getElementById('mn-15-root');

  const COMMANDS = [
    { group: 'Pages', icon: '🏠', label: 'Dashboard', desc: 'Main workspace' },
    { group: 'Pages', icon: '📊', label: 'Analytics', desc: 'Traffic & conversions' },
    { group: 'Pages', icon: '👥', label: 'Team Members', desc: '12 members' },
    { group: 'Pages', icon: '⚙️', label: 'Settings', desc: 'Account & preferences' },
    { group: 'Actions', icon: '✍️', label: 'New Document', desc: 'Create blank page', shortcut: 'N' },
    { group: 'Actions', icon: '📤', label: 'Export', desc: 'Download as PDF', shortcut: 'E' },
    { group: 'Actions', icon: '🔗', label: 'Copy Link', desc: 'Share current page', shortcut: 'L' },
    { group: 'Actions', icon: '🎨', label: 'Change Theme', desc: 'Appearance settings' },
    { group: 'Recent', icon: '📄', label: 'Product Roadmap', desc: '10 min ago' },
    { group: 'Recent', icon: '💬', label: 'Design Review', desc: '1 hour ago' },
  ];

  function render(q) {
    const filtered = q ? COMMANDS.filter(c => c.label.toLowerCase().includes(q.toLowerCase()) || c.desc.toLowerCase().includes(q.toLowerCase())) : COMMANDS;
    if (!filtered.length) {
      results.innerHTML = `<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:13px">No results for "${q}"</div>`;
      return;
    }
    let html = '';
    let lastGroup = '';
    filtered.forEach(c => {
      if (c.group !== lastGroup) {
        if (lastGroup) html += '<div class="mn-15__divider"></div>';
        html += `<div class="mn-15__group-label">${c.group}</div>`;
        lastGroup = c.group;
      }
      html += `<div class="mn-15__result-item">
        <div class="mn-15__result-icon">${c.icon}</div>
        <div class="mn-15__result-info"><h4>${c.label}</h4><p>${c.desc}</p></div>
        ${c.shortcut ? `<div class="mn-15__result-shortcut"><kbd>${c.shortcut}</kbd></div>` : ''}
      </div>`;
    });
    results.innerHTML = html;
  }

  function open() { palette.classList.add('is-open'); setTimeout(() => input.focus(), 50); render(''); }
  function close() { palette.classList.remove('is-open'); input.value = ''; clear.classList.remove('is-visible'); }

  trigger.addEventListener('click', open);
  palette.addEventListener('click', e => { if (e.target === palette) close(); });

  input.addEventListener('input', () => {
    render(input.value);
    clear.classList.toggle('is-visible', input.value.length > 0);
  });

  clear.addEventListener('click', () => { input.value = ''; render(''); clear.classList.remove('is-visible'); input.focus(); });

  document.addEventListener('keydown', e => {
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); palette.classList.contains('is-open') ? close() : open(); }
    if (e.key === 'Escape') close();
  });

  render('');
})();

How this works

The palette overlay is hidden via opacity: 0; pointer-events: none. A JS open() function adds is-open to the palette, which transitions it to opacity: 1 and triggers the inner box transition from scale(0.95) translateY(-8px) to scale(1) translateY(0) with a spring cubic-bezier. The trigger bar click, and a keydown listener for metaKey + k, both call the same open().

Filtering is pure JS: the COMMANDS array is filtered by label.toLowerCase().includes(query) and the results div is rebuilt with innerHTML on every input event. Group labels are inserted when the group name changes, with a divider between groups. When the query is empty, all commands render with their original grouping.

Customize

  • Add more commands by appending objects to the COMMANDS array with { group, icon, label, desc, shortcut } — the filter and group-label logic adapts automatically.
  • Enable keyboard arrow navigation by adding ArrowUp/ArrowDown keydown handlers that toggle is-selected class across result items and scroll them into view.
  • Change the trigger shortcut from metaKey + k to any combination by editing the e.metaKey && e.key === "k" condition in the keydown listener.
  • Persist recent commands by storing selected command IDs in localStorage and rendering a "Recent" group before the others when the query is empty.
  • Animate result items on filter by applying a translateY(4px) opacity(0) to translateY(0) opacity(1) transition to each .mn-15__result-item when the list rebuilds.

Watch out for

  • Rebuilding innerHTML on every keystroke discards DOM state — if result items have focus state, those are lost on each character typed; use DOM diffing for production.
  • The metaKey shortcut only fires on Mac; Windows users need ctrlKey — use (e.metaKey || e.ctrlKey) && e.key === "k" for cross-platform support.
  • The overlay click handler closes on outside click via if (e.target === palette) — this only works if the palette background is the direct click target; child element clicks will not close it.

Browser support

ChromeSafariFirefoxEdge
80+ 14+ 75+ 80+

All JS APIs used (KeyboardEvent.metaKey, template literals, Array.filter) are universally supported in modern browsers.

Search CodeFronts

Loading…