On this page

Installation

Install the package with your package manager:

npm install glyphx

GlyphX works with React 18 and 19. It depends on Radix Dialog, Radix Visually Hidden, Zustand and fuzzysort, which your package manager shares with the rest of your app.

Then import the stylesheet once, where your app starts:

main.tsx
import "glyphx/style.css";

The stylesheet only styles GlyphX's own elements and only defines --glyphx-* variables. It won't change anything else on your page.

Quick start

Render one GlyphX component with a list of commands, anywhere in your app:

App.tsx
import { GlyphX, type Command } from "glyphx";
import "glyphx/style.css";

// Outside the component, so it stays
// the same between renders.
const commands: Command[] = [
  {
    id: "home",
    title: "Go to Home",
    group: "Navigation",
    run: () => window.location.assign("/"),
  },
  {
    id: "dark-mode",
    title: "Toggle dark mode",
    subtitle: "Light or dark",
    keywords: ["theme", "night"],
    group: "Settings",
    // Your app's own dark mode. Pass
    // theme={…} so GlyphX follows it.
    run: () => {
      const el = document.documentElement;
      el.classList.toggle("dark");
    },
  },
];

export function App() {
  return <GlyphX commands={commands} />;
}

Press ⌘ K to open it. Type to search, use ↑ ↓ to choose, press Enter to run a command and Esc to close.

Define the commands outside your component, or wrap them in useMemo, so the array stays the same between renders. GlyphX re-indexes the list whenever it changes.

Commands

Each command is a plain object:

FieldTypeWhat it does
idstringUnique and stable. Keeps the selection on the same command when the list changes.
titlestringThe text people see and search first.
subtitlestringOptional second line of detail. Also searched.
keywordsstring[]Optional extra search words, never shown. Great for synonyms.
shortcutstringOptional label on the right, such as “⌘ S”. Display only: GlyphX doesn't bind it.
groupstringOptional heading to list the command under. Commands without one go under “Other”.
run() => unknownWhat the command does. It may return anything, including a promise; GlyphX ignores the result.

Running commands

When someone picks a command, GlyphX closes the palette, returns focus to where it was, and then calls run. Because the palette is gone by then, a command can freely move focus, open a dialog or navigate.

  • It doesn't wait. If run returns a promise, the palette is already closed. Show progress your own way for long tasks.
  • Errors are contained. If run throws or its promise rejects, GlyphX logs [glyphx] Command failed: <id> to the console and carries on. Catch errors yourself to show a message.
An async command with feedback
{
  id: "export-pdf",
  title: "Export as PDF",
  run: async () => {
    // Any toast library works
    const id = toast.loading("Exporting…");
    try {
      await exportPdf();
      toast.success("Exported", { id });
    } catch {
      toast.error("Export failed", { id });
    }
  },
}

Try it on this site: open the palette and run “Run a slow task” or “Run a command that fails”.

Keyboard shortcuts

By default, either of these opens the palette:

SystemShortcuts
macOS⌘ K or ⌘ Shift P
Windows and LinuxCtrl K or Ctrl Shift P

Pressing the shortcut while the palette is open does nothing. Close it with Esc or a click outside.

Change the shortcut

Pass the shortcut prop. It replaces both defaults. You choose the key, and optionally shift or alt; ⌘ on macOS or Ctrl elsewhere is always added.

TSX
// Only ⌘/Ctrl + K
<GlyphX
  commands={commands}
  shortcut={{ key: "k" }}
/>

// ⌘/Ctrl + K or ⌘/Ctrl + /
<GlyphX
  commands={commands}
  shortcut={[{ key: "k" }, { key: "/" }]}
/>

// ⌘/Ctrl + Shift + P
<GlyphX
  commands={commands}
  shortcut={{ key: "p", shift: true }}
/>

GlyphX handles its shortcuts before the rest of the page. If a text editor in your app uses ⌘ K to insert a link, use the last example instead.

Some shortcuts never reach the page. Browsers keep these for themselves:

  • ⌘ N, T and W (new window, new tab, close tab) in every browser
  • ⌘ Shift P in Firefox (private window), so Firefox users use ⌘ K

Also avoid ones people rely on, such as copy, paste, save, find and print, and try your shortcut in each browser.

Inside the palette

KeyAction
↑ ↓Move the selection (wraps around)
EnterRun the selected command
EscClose without running anything
MouseHover to select, click to run

Opening it from your own UI

To open the palette from a button, or to know when it opens, pass open and onOpenChange:

Header.tsx
import { useState } from "react";
import { GlyphX } from "glyphx";

export function Header() {
  const [open, setOpen] = useState(false);

  return (
    <header>
      <button onClick={() => setOpen(true)}>
        Search… ⌘K
      </button>
      <GlyphX
        commands={commands}
        open={open}
        onOpenChange={setOpen}
      />
    </header>
  );
}

Once you pass open, GlyphX follows it and never opens or closes on its own. The shortcut, Esc, clicking outside and running a command all call onOpenChange with the state they ask for, and you decide. Leave both props out and GlyphX manages itself.

Themes and colors

Light, dark or system

GlyphX follows the device's light or dark setting. If your app has its own theme switch, pass theme:

With next-themes
"use client";
import { useTheme } from "next-themes";
import { GlyphX } from "glyphx";

export function CommandPalette() {
  const { resolvedTheme } = useTheme();
  const dark = resolvedTheme === "dark";

  return (
    <GlyphX
      commands={commands}
      theme={dark ? "dark" : "light"}
    />
  );
}

Colors

Every color GlyphX uses is a CSS variable:

VariableUsed for
--glyphx-accentMatched letters
--glyphx-selectedSelected row background
--glyphx-fgText
--glyphx-mutedSubtitles, group headings, shortcuts, placeholder
--glyphx-borderPalette border
--glyphx-glassPalette background, with see-through amount (e.g. 255 255 255 / 0.92)

Each value is three numbers from 0 to 255, for red, green and blue: 139 92 246 is purple. To change a color, set it in your own CSS, loaded after glyphx/style.css. This changes the accent in both light and dark:

globals.css
:root,
[data-glyphx-theme] {
  --glyphx-accent: 139 92 246;
}

Try it here: open the palette and run an “Accent color” command. This site recolors GlyphX the same way.

Different colors in dark mode

To use one color in light mode and another in dark, set each separately. Dark mode needs two rules: one for when GlyphX follows the device setting, and one for theme="dark".

globals.css
/* Light */
:root,
[data-glyphx-theme="light"] {
  --glyphx-accent: 147 51 234;
  --glyphx-selected: 243 232 255;
}

/* Dark, following the device */
@media (prefers-color-scheme: dark) {
  :root:not([data-glyphx-theme="light"]) {
    --glyphx-accent: 192 132 252;
    --glyphx-selected: 59 7 100;
  }
}

/* Dark, with theme="dark" */
[data-glyphx-theme="dark"] {
  --glyphx-accent: 192 132 252;
  --glyphx-selected: 59 7 100;
}

Styling individual parts

For changes the color variables don't cover, target these class names in your own CSS, loaded after glyphx/style.css:

ClassElement
.glyphx-overlayThe dimmed backdrop behind the palette
.glyphx-contentThe palette box itself
.glyphx-hintThe “Esc to close” hint
.glyphx-inputThe search field
.glyphx-listThe scrolling list of results
.glyphx-group-headingA group's heading
.glyphx-itemOne command row (aria-selected="true" when selected)
.glyphx-item-subtitleA command's subtitle
.glyphx-item-shortcutA command's shortcut label
.glyphx-highlightThe matched letters
.glyphx-emptyThe “No matching commands” message
Example
.glyphx-content {
  border-radius: 8px;
}
.glyphx-input {
  font-size: 18px;
}

Frosted glass

The palette's background is slightly see-through (92% opaque). For a frosted-glass look, like this website's, blur what's behind it:

CSS
.glyphx-content {
  backdrop-filter: blur(20px);
}

Next.js

GlyphX is marked "use client", so it works in the App Router. Commands contain functions, though, and Next.js doesn't let a Server Component pass functions to a client component. So define your commands in a small client component, which also lets them use the router:

app/palette.tsx
"use client";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { GlyphX, type Command } from "glyphx";

export function CommandPalette() {
  const router = useRouter();

  const commands = useMemo<Command[]>(
    () => [
      {
        id: "home",
        title: "Home",
        group: "Pages",
        run: () => router.push("/"),
      },
      {
        id: "blog",
        title: "Blog",
        group: "Pages",
        run: () => router.push("/blog"),
      },
    ],
    [router]
  );

  return <GlyphX commands={commands} />;
}

Then render it once from your root layout, which stays a Server Component:

app/layout.tsx
import "glyphx/style.css";
import { CommandPalette } from "./palette";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <CommandPalette />
      </body>
    </html>
  );
}

Without a bundler

For a page without a build step, load React 18 and GlyphX from a CDN. GlyphX then appears as the GlyphX global.

index.html
<link
  rel="stylesheet"
  href="https://unpkg.com/glyphx@0.5/dist/index.css"
/>
<div id="palette"></div>

<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/glyphx@0.5/dist/index.umd.js"></script>
<script>
  const root = document.getElementById("palette");
  ReactDOM.createRoot(root).render(
    React.createElement(GlyphX.GlyphX, {
      commands: [
        {
          id: "hello",
          title: "Say hello",
          run: () => alert("Hello"),
        },
      ],
    })
  );
</script>

React 19 doesn't publish these script-tag builds, so this needs React 18.

Testing your app

GlyphX uses standard roles, so Testing Library queries work as you'd expect. Four things to know:

  • Commands run just after the palette closes, so use waitFor.
  • jsdom lacks scrollIntoView, so stub it.
  • GlyphX keeps its state in one shared store, so close the palette after each test, or the next test starts with it open.
  • Unless Vitest's globals option is on, Testing Library doesn't remove rendered components between tests, so call cleanup yourself. Otherwise palettes from earlier tests stay on the page, still listening for the shortcut, which can make later tests fail in confusing ways.
palette.test.tsx
import {
  afterEach, expect, test, vi,
} from "vitest";
import "@testing-library/jest-dom/vitest";
import {
  cleanup, render, screen, fireEvent, waitFor,
} from "@testing-library/react";
import { GlyphX } from "glyphx";

// jsdom doesn't have scrollIntoView
Element.prototype.scrollIntoView = vi.fn();

// GlyphX's state is shared: close the
// palette after each test, then clean up.
afterEach(() => {
  const search =
    screen.queryByRole("combobox");
  if (search) {
    fireEvent.keyDown(search, {
      key: "Escape",
    });
  }
  cleanup();
});

test("runs a command", async () => {
  const run = vi.fn();
  render(
    <GlyphX
      commands={[
        { id: "save", title: "Save", run },
      ]}
    />
  );

  // jsdom isn't a Mac: the shortcut is Ctrl
  fireEvent.keyDown(window, {
    key: "k",
    ctrlKey: true,
  });

  const search = screen.getByRole("combobox");
  fireEvent.change(search, {
    target: { value: "save" },
  });
  const option = screen.getByRole("option", {
    name: "Save",
  });
  expect(option).toBeVisible();

  fireEvent.keyDown(search, { key: "Enter" });
  await waitFor(() =>
    expect(run).toHaveBeenCalledOnce()
  );
});

API reference

<GlyphX /> props

PropTypeDefaultDescription
commandsCommand[]requiredThe commands to show and search.
openboolean—Makes the palette controlled: it is open exactly when this is true.
onOpenChange(open: boolean) => void—Called with the requested state when the shortcut, Esc, an outside click or picking a command would open or close the palette. Not called when you change open yourself.
shortcutShortcutConfig | ShortcutConfig[][{ key: "k" }, { key: "p", shift: true }]The key combinations that open the palette. Passing it replaces the defaults.
theme"system" | "light" | "dark""system"The color scheme.

Types

TSX
type Command = {
  id: string;
  title: string;
  subtitle?: string;
  keywords?: string[];
  shortcut?: string;
  group?: string;
  run: () => unknown;
};

type ShortcutConfig = {
  key: string;     // "k", "p", "/" …
  shift?: boolean; // also require Shift
  alt?: boolean;   // also require Alt
};

type GlyphXTheme = "system" | "light" | "dark";

Package entry points

Entry pointContents
glyphxThe GlyphX component and its types (ESM and CommonJS)
glyphx/style.cssThe stylesheet
https://unpkg.com/glyphx@0.5/dist/index.umd.jsThe script-tag build (load it from a CDN; it can't be imported)

TypeScript

The package includes types for both import and require.

TSX
import type {
  Command,        // one command
  GlyphXProps,    // the component's props
  GlyphXTheme,    // "system", "light", "dark"
  ShortcutConfig, // one keyboard shortcut
} from "glyphx";

// Build commands from your own data:
type Page = { path: string; name: string };

const toCommand = (page: Page): Command => ({
  id: page.path,
  title: page.name,
  group: "Pages",
  run: () => navigate(page.path),
});

Accessibility

GlyphX handles the accessibility details for you:

  • It opens as a dialog with a name and description for screen readers.
  • Focus moves into the search field, stays inside while open, and returns to where it was when the palette closes.
  • The search field is a combobox linked to a listbox of options, so screen readers announce the selected command as you move.
  • Each group is announced with its heading, and subtitles and shortcut labels are read as descriptions.
  • “No matching commands” is announced when a search finds nothing.
  • Animations are skipped when the device asks for reduced motion.

Your part: write titles that make sense on their own (“Open settings”, not “Settings →”), and use subtitles for context.

Tips and tricks

  1. Use keywords for the words people actually type. Synonyms, abbreviations and old names: “prefs”, “config” and “options” for Settings.
  2. Generate commands from your data. Routes, projects, recent files and team members all make great commands.
  3. Make commands depend on context. Add or remove commands for the current page, the user's role or a feature flag. The palette updates live, and keeps the search and selection if it's open.
  4. Show the shortcuts your app already has. The shortcut label teaches people faster ways to do things, even though GlyphX doesn't bind it.
  5. Put a search button in your header. Not everyone knows the shortcut, and phones don't have one. Use controlled mode to open the palette from the button.
  6. Match your brand. Override --glyphx-accent and --glyphx-selected with your colors.

FAQ and troubleshooting

The shortcut doesn't work in Firefox.

Firefox keeps Ctrl/⌘ Shift P for a private window and never passes it to the page. Use Ctrl/⌘ K, which GlyphX also listens for by default. If you set your own shortcut, include one Firefox doesn't keep (see Keyboard shortcuts).

Does the shortcut work with non-English keyboards?

Yes. Letters and digits work on every keyboard layout. On Dvorak or Colemak, people press the key labelled with the letter. On layouts that don't type Latin letters, such as Russian, and with Option on macOS, GlyphX uses the key in that letter's position.

The palette appears behind my header.

The palette and its backdrop use z-index: 50, which keeps them above most sticky headers. If your header is higher, raise GlyphX's:

CSS
.glyphx-overlay,
.glyphx-content {
  z-index: 1000;
}
Next.js says functions can't be passed to client components.

Define your commands inside a client component instead of a Server Component (see Next.js).

Can I have two palettes on one page?

No. GlyphX keeps its state in one shared store, so two instances would share it. Render one, in your root layout, and give it all your commands.

How do I open it from my own button?

Pass open and onOpenChange (see Opening it from your own UI).

Can I translate or change the palette's built-in text?

Not yet. The placeholder (“Type a command…”), the empty message (“No matching commands”) and the “Esc to close” hint are always in English. Your command titles, subtitles and group names can be in any language, and search works with them.

Importing the stylesheet changed the rest of my page.

That was true up to version 0.2.2, which shipped a CSS reset. Since 0.3.0 the stylesheet only styles the palette. Update to the latest version.

TypeScript can't find GlyphX's types.

With "moduleResolution": "node16" or "nodenext", versions before 0.3.0 had broken type declarations. Update to the latest version.

Can I use GlyphX at my company?

GlyphX is under the PolyForm Noncommercial License: it's free for personal and other noncommercial use. Commercial use, including inside a company, needs permission. Email the developer to ask.

Does GlyphX collect any data?

No. It makes no network requests and stores nothing. Your commands run your own code inside your app. See the privacy page.