Documentation
Everything you need to add GlyphX to your app, make it look like yours, and get the most out of it.
On this page
Getting started
Guides
Reference
Installation
Install the package with your package manager:
npm install glyphxGlyphX 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:
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:
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:
| Field | Type | What it does |
|---|---|---|
id | string | Unique and stable. Keeps the selection on the same command when the list changes. |
title | string | The text people see and search first. |
subtitle | string | Optional second line of detail. Also searched. |
keywords | string[] | Optional extra search words, never shown. Great for synonyms. |
shortcut | string | Optional label on the right, such as “⌘ S”. Display only: GlyphX doesn't bind it. |
group | string | Optional heading to list the command under. Commands without one go under “Other”. |
run | () => unknown | What the command does. It may return anything, including a promise; GlyphX ignores the result. |
Groups, subtitles and search
Here's a command that uses every field:
{
id: "settings",
title: "Open settings",
subtitle: "Account and billing",
keywords: ["preferences", "config"],
shortcut: "⌘ ,",
group: "Navigation",
run: () => router.push("/settings"),
}How search works
- Matching is fuzzy and ignores case and accents: “stngs” finds “Open settings”.
- Title, subtitle and keywords are matched separately. For equally good matches, the title wins over the subtitle, which wins over keywords.
- Words separated by spaces can match different fields: “settings billing” matches the title and the subtitle above.
- Matched letters are highlighted in the title, or in the subtitle when the title doesn't match. Keyword matches aren't highlighted, because keywords aren't shown.
How groups are ordered
With an empty search, groups appear in the order of their first command in your array, and commands keep your order. While searching, the group with the best match comes first. Put your most important commands first.
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
runreturns a promise, the palette is already closed. Show progress your own way for long tasks. - Errors are contained. If
runthrows or its promise rejects, GlyphX logs[glyphx] Command failed: <id>to the console and carries on. Catch errors yourself to show a message.
{
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:
| System | Shortcuts |
|---|---|
| macOS | ⌘ K or ⌘ Shift P |
| Windows and Linux | Ctrl 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.
// 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
| Key | Action |
|---|---|
| ↑ ↓ | Move the selection (wraps around) |
| Enter | Run the selected command |
| Esc | Close without running anything |
| Mouse | Hover 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:
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:
"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:
| Variable | Used for |
|---|---|
--glyphx-accent | Matched letters |
--glyphx-selected | Selected row background |
--glyphx-fg | Text |
--glyphx-muted | Subtitles, group headings, shortcuts, placeholder |
--glyphx-border | Palette border |
--glyphx-glass | Palette 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:
: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".
/* 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:
| Class | Element |
|---|---|
.glyphx-overlay | The dimmed backdrop behind the palette |
.glyphx-content | The palette box itself |
.glyphx-hint | The “Esc to close” hint |
.glyphx-input | The search field |
.glyphx-list | The scrolling list of results |
.glyphx-group-heading | A group's heading |
.glyphx-item | One command row (aria-selected="true" when selected) |
.glyphx-item-subtitle | A command's subtitle |
.glyphx-item-shortcut | A command's shortcut label |
.glyphx-highlight | The matched letters |
.glyphx-empty | The “No matching commands” message |
.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:
.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:
"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:
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.
<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
globalsoption is on, Testing Library doesn't remove rendered components between tests, so callcleanupyourself. Otherwise palettes from earlier tests stay on the page, still listening for the shortcut, which can make later tests fail in confusing ways.
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
| Prop | Type | Default | Description |
|---|---|---|---|
commands | Command[] | required | The commands to show and search. |
open | boolean | — | 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. |
shortcut | ShortcutConfig | 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
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 point | Contents |
|---|---|
glyphx | The GlyphX component and its types (ESM and CommonJS) |
glyphx/style.css | The stylesheet |
https://unpkg.com/glyphx@0.5/dist/index.umd.js | The script-tag build (load it from a CDN; it can't be imported) |
TypeScript
The package includes types for both import and require.
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
- Use keywords for the words people actually type. Synonyms, abbreviations and old names: “prefs”, “config” and “options” for Settings.
- Generate commands from your data. Routes, projects, recent files and team members all make great commands.
- 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.
- Show the shortcuts your app already has. The
shortcutlabel teaches people faster ways to do things, even though GlyphX doesn't bind it. - 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.
- Match your brand. Override
--glyphx-accentand--glyphx-selectedwith 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:
.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.