Composites
Keyboard Hint
A shortcut written the way the reader's own platform writes it — ⌘K on a Mac, Ctrl+K everywhere else — from one string. It renders Kbd boxes, hides itself from screen readers, and ships with the platform check useIsMac that decides which set of glyphs to use.
Basic usage
One prop. keys is the combination written as tokens joined by +, and the component turns each token into the right label for the machine it is running on.
The token vocabulary
Tokens are matched case-insensitively, in a fixed order: the platform modifier table first, then the shared named-key table, then a fall-through. The two tables do not overlap today, so the order only matters if a name is ever added to both — a modifier would win.
Modifiers — resolved per platform
| Token | macOS | Elsewhere | Notes |
|---|---|---|---|
mod | ⌘ | Ctrl | The primary command key. Reach for this one. |
cmd | ⌘ | Ctrl | Alias of mod — it does NOT print "Cmd" on Windows. |
meta | ⌘ | Ctrl | Alias of mod. |
ctrl | ⌃ | Ctrl | The literal Control key, distinct from ⌘ on a Mac. |
control | ⌃ | Ctrl | Alias of ctrl. |
alt | ⌥ | Alt | Option on a Mac. |
option | ⌥ | Alt | Alias of alt. |
shift | ⇧ | Shift |
Named keys — the same on every platform
| Token | Rendered |
|---|---|
enter · return | ↵ |
esc · escape | Esc |
space | Space |
tab | Tab |
backspace | ⌫ |
delete | Del |
up · down · left · right | ↑ ↓ ← → |
Everything else falls through: a single character is upper-cased (k → K), and a longer unknown token is printed exactly as you typed it, case and all — so write F5, not f5. Empty segments are discarded before rendering, which means "mod++" renders a lone ⌘ and there is no way to show a literal plus key. A keys string that resolves to nothing renders null, not an empty box.
compact
By default each key gets its own Kbd inside a KbdGroup, separated by the group's gap — no + glyphs are drawn. compact collapses the lot into a single box, which is what you want inside a menu row or a tight toolbar where three little boxes read as clutter.
The join differs by platform, on purpose: macOS runs the symbols together the way the OS itself writes them (⌘⇧P), while everywhere else the labels are joined with + (Ctrl+Shift+P) — CtrlShiftP would be unreadable.
useIsMac and the first frame
The hook that KeyboardHint runs on is exported for your own copy — a tooltip that says “Hold ⌥ to duplicate”, a help panel, an onboarding step.
This browser reports useIsMac() === false
On a Mac this box was briefly the other one. The first frame is always false.
It returns false on the first render — on a Mac too, and that is deliberate.
The server has no idea what operating system the request came from. Guess, and the HTML the server produced disagrees with what the client renders — React warns about the hydration mismatch and, in the worst case, throws away the whole subtree and re-renders it. Showing Ctrl for one frame and then swapping to ⌘ is the cheaper of the two failures.
The mechanism is useSyncExternalStore with a server snapshot hard-wired to false, rather than the usual useState + useEffect pair with a mounted flag. React does the server/client reconciliation itself, so there is no manual flag to forget. The subscribe function is a no-op: an operating system does not change mid-session, so there is nothing to listen to.
The detection reads navigator.platform before navigator.userAgent. Recent Safari and Chrome have frozen and trimmed the user-agent string, but platform still returns "MacIntel" — including on iPadOS in desktop mode.
// The implementation, for reference — this is what ships.
const emptySubscribe = () => () => {}
export function useIsMac(): boolean {
return useSyncExternalStore(
emptySubscribe, // nothing to subscribe to
() => /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent),
() => false // the server snapshot
)
}The practical consequence: never let a layout depend on the width of the result. A hint that goes from Ctrl to ⌘ gets narrower on the second frame, and anything measuring or centring around it will visibly shift. And never branch behaviour on it during render — bind both metaKey and ctrlKey in the handler and use the hook only to decide what to draw.
Import it from the root entry, only
useIsMac ships from @comitor/ui and from nowhere else. The shell tier uses it internally — that is how AppHeader renders its search shortcut — but it does not re-export it, and importing it from @comitor/ui/shell is a build error rather than a subtle one.
The rule exists because of a real bug. Version 0.1.0 exported this name from both entries with two different bodies — the shell copy used useState + useEffect reading the user agent — so the same import name gave you two different hydration behaviours depending on which door you came through, and export * is completely silent about a duplicate name across entries. 0.2.0 closed the second door.
Which door survived was not arbitrary either. The body has to live in Tier 2, because KeyboardHint is not allowed to drag in /shell's next peer dependency. And an app that uses the shell has already imported the root entry for Button and Badge, while an app that does not use the shell would have found the hook locked behind an optional peer it never needed.
// ✓ The only door. useIsMac is exported from the root entry and nowhere else.
import { KeyboardHint, useIsMac } from '@comitor/ui'
// ✗ Does not exist. /shell uses this hook internally (AppHeader) but does not
// re-export it. In 0.1.0 it DID — with a different implementation behind the
// same name — and `export *` says nothing about a collision across entries.
import { useIsMac } from '@comitor/ui/shell'In context
The hint is decoration attached to a control that actually implements the shortcut. Two places it belongs: on the trigger of a global action, and at the right edge of a menu row (where DropdownMenuShortcut already supplies the ml-auto, so compact keeps the row from getting tall). Open the menu below — the shortcuts are real KeyboardHints.
Note the aria-label on the search button. Because the hint is hidden from assistive technology, the shortcut has to be spelled into the control's own name or it does not exist for a screen-reader user at all.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
keysrequired | string | — | The combination, written as tokens joined by "+" — "mod+k", "mod+shift+n", "esc". Case-insensitive for known tokens. Empty segments are dropped, and a combination that resolves to nothing renders null. |
compact | boolean | false | Collapses the whole combination into one Kbd instead of one per key. On macOS the tokens are joined with nothing (⌘K); elsewhere with "+" (Ctrl+K). |
className | string | — | Lands on the Kbd in compact mode and on the KbdGroup otherwise. The group already carries shrink-0, so it survives being placed in a flex row that runs out of space. |
KeyboardHintProps is a closed interface — there is no rest spread, so id, title and data-* attributes do not pass through. Wrap it if you need them, or drop to Kbd / KbdGroup directly.
useIsMac
| Prop | Type | Default | Description |
|---|---|---|---|
useIsMac() | () => boolean | false (first render) | True when the client is macOS, iPhone, iPad or iPod. Reads navigator.platform first and falls back to navigator.userAgent. Returns false on the server and on the first client render, always — see the hydration note above. |
Accessibility
- The whole component is
aria-hidden="true"— set on theKbdin compact mode and on theKbdGroupotherwise. A run of ⌘⇧↵ read aloud is noise: screen readers announce those glyphs as “place of interest sign”, “upwards white arrow” and so on. - So the control owes its own name. Put the shortcut in the
aria-labelof the button or menu item that carries it, in words —aria-label="Open the command palette, Control K". Without that, the shortcut is invisible to exactly the users most likely to want it. - It renders native
<kbd>elements, which is the right semantic even while hidden, and which is what you get back if you ever remove thearia-hiddenby usingKbddirectly. Kbdispointer-events-noneandselect-none, so a hint inside a button never swallows the click and never becomes a stray selection when someone drags across the row.- Colour is
bg-mutedwithtext-muted-foreground— a role pair, so it follows both colour palettes and both themes. Inside a tooltip it flips to a translucentbg-background/20withtext-background, because the tooltip surface is inverted and the normal pair would vanish into it. - The box is sized in
rem(h-5 min-w-5,text-xs), so it grows with thedata-font-sizedisplay axis instead of trapping enlarged text in a fixed 20px box. - A hint is a promise. If it says
mod+k, the surrounding control must genuinely respond to bothmetaKeyandctrlKey— the component renders the label but binds nothing. - Never make the hint the only way to discover an action. It is hidden from assistive technology and absent on touch devices, so anything reachable only by shortcut is unreachable for a share of your users.