KbdShortcut
Platform-aware keyboard shortcut display.
Live demo
01Single keys
Mac combos (forced)
mac={true} renders Mac glyphs: β β β₯ β§ β β₯ β«.
Windows combos (forced)
mac={false} renders readable text: Ctrl, Shift, Alt, Win.
Auto-detect
Omit mac and the component picks the right glyph for your platform.
Sizes
Sequential combos
Use a custom separator like " β " to indicate "press g, then s".
In context
- Open command palette Win K
- Save changes Win S
- Toggle sidebar Win B
- Go to inbox G I
Implementation
02<script lang="ts">
import KbdShortcut from '$lib/components/KbdShortcut.svelte';
</script>
β
<KbdShortcut keys={['Cmd', 'K']} />
<KbdShortcut keys={['G', 'S']} separator=" β " />KbdShortcut renders each key as a real <kbd> element so screen readers know it represents keyboard input. The component auto-detects Mac vs Windows from navigator.platform (default to Mac on the server so SSR content matches the most common laptop locale, then corrects on hydration) and substitutes Cmd β β, Ctrl β β on Mac, leaving readable text on Windows. The default aria-label spells the keys as words ("Cmd plus K") so screen readers say words, not glyphs.
Logic explainer
03What Does It Do? (Plain English)
Renders one or more keys as styled <kbd> caps so users can see at a glance which key combo triggers an action. Auto-detects platform (Mac vs Windows/Linux) and substitutes the correct modifier glyphs β β on Mac, Ctrl/Win on Windows. Passing keys={['Cmd', 'K']} produces β K on a Mac and Win + K on Windows; passing keys={['G', 'S']} with a custom separator=" β " produces a sequential combo like G β S.
Think of it as a typographer's tool, not a wired-up shortcut handler. The component shows the combo; firing the action when the user actually presses it is wholly the consumer's job.
How It Works (Pseudo-Code)
state:
keys = string | string[]
mac = optional boolean override
derive isMac:
if mac prop is boolean: return mac (consumer wins)
if running on server (no navigator): return true (Mac is the safer SSR default)
return /Mac|iPod|iPhone|iPad/.test(navigator.platform)
derive keyList = Array.isArray(keys) ? keys : [keys]
derive renderedKeys = keyList.map(k => symbolFor(k, isMac))
symbolFor(key, onMac):
switch key.toLowerCase():
case 'cmd' | 'command' | 'meta': β 'β' on Mac, 'Win' on Windows
case 'ctrl' | 'control': β 'β' on Mac, 'Ctrl' on Windows
case 'alt' | 'option' | 'opt': β 'β₯' on Mac, 'Alt' on Windows
case 'shift': β 'β§' on Mac, 'Shift' on Windows
case 'enter' | 'return': β 'β' on Mac, 'Enter' on Windows
case 'esc': β 'esc' / 'Esc'
case 'tab' | 'backspace' | 'space' | arrow keys: β glyph or label
default: return key as-is
derive computedAriaLabel:
ariaLabel ?? keyList.join(' plus ')
// "Cmd plus K" reads better than "command-K-symbol" via SR
render:
<kbd aria-label={computedAriaLabel}>
for each rendered symbol:
if not first: <span aria-hidden>{separator}</span>
<span>{symbol}</span>
</kbd>The component is essentially a lookup table wrapped in pretty CSS. The clever bits are SSR-safe platform detection and the aria-label that spells out modifier names instead of their glyphs.
The Core Concept: Platform-Aware Symbol Substitution
The same shortcut renders differently depending on platform:
| Logical key | Mac glyph | Windows label |
|---|---|---|
Cmd |
β | Win |
Ctrl |
β | Ctrl |
Alt |
β₯ | Alt |
Shift |
β§ | Shift |
Enter |
β | Enter |
Tab |
β₯ | Tab |
Backspace |
β« | Backspace |
Space |
β£ | Space |
| Arrow keys | β β β β | β β β β |
The Mac column uses Unicode glyphs that match the keycaps Apple ships. The Windows column uses readable text labels because the Windows keyboard doesn't have a visual symbol for "Win" or "Ctrl" β Microsoft's design language is text-first.
navigator.platform is the detection vector. It's been deprecated in favour of navigator.userAgentData.platform for a few years, but the new API is still gated by browser permission prompts (User-Agent Client Hints) on some configurations, so we stick with the legacy property β it's reliable for the Mac/non-Mac binary we actually need.
SSR-Safe Auto-Detection
navigator is undefined on the server. A naΓ―ve navigator.platform.includes('Mac') would crash during SSR. The fix is a guard:
const isMac = $derived.by(() => {
if (typeof mac === 'boolean') return mac; // explicit prop wins
if (typeof navigator === 'undefined') return true; // SSR: assume Mac
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
});Why default to Mac on the server? Two reasons:
- Hydration mismatch is harmless either way. The server renders one value; the client re-derives on hydration. If the user is on Windows, the kbd caps re-render from β to Ctrl on the first hydration tick β a flicker, but the underlying text content is correct after hydration.
- Mac is the more common dev/laptop environment for the audience this component primarily serves (early-2026 Svelte template gallery). Defaulting to Mac means most users see the right glyphs even before hydration completes.
If you want to force a specific platform regardless of detection (e.g. a docs page that always shows Windows shortcuts), pass mac={false} explicitly. The typeof mac === 'boolean' check ensures the consumer's choice wins.
Accessibility: Spelling Out the Combo
The default aria-label is keyList.join(' plus '). So <KbdShortcut keys={['Cmd', 'K']} /> gets aria-label="Cmd plus K". AT users hear "kbd, Cmd plus K" β a clear, speakable shortcut.
Without this, the alternative is having AT read the literal glyphs, which produces output like "kbd, command symbol K" or worse, garbled depending on the SR's glyph dictionary. Words always beat glyphs for accessibility.
The separator characters between keys (+, β, or whatever the consumer passes) are wrapped in <span aria-hidden="true"> so AT skips them. The aria-label already encodes "plus" between keys; the visual separator is for the eye only.
The whole combo is one <kbd> element rather than per-key <kbd>s. Screen readers announce the combo as a single unit ("kbd, Cmd plus K") rather than three separate kbd announcements ("kbd Cmd, plus, kbd K"). The semantic boundary matches the human concept of "one shortcut".
State Flow Diagram
PROPS RENDER
keys = ['Cmd', 'K']
mac = undefined βββββββββββββββββββββββ
β <kbd aria-label= β
β "Cmd plus K"> β
βββββββββββββββββββββββββββ β <span>β</span> β
β <span +>+</span> β β aria-hidden
isMac = navigator.platform β <span>K</span> β
matches Mac? β </kbd> β
(server: true, client: real) βββββββββββββββββββββββ
βββββββββββββββββββββββββββ
renderedKeys = keyList.map(symbolFor)
'Cmd' on Mac β 'β'
'K' β 'K'
βββββββββββββββββββββββββββ
On hydration mismatch (Windows user):
server rendered 'β K'
client re-derives β 'Win + K'
Svelte updates DOM in place
prefers-color-scheme = dark:
7 chrome tokens flip via @media block
no Pattern #67 split (no brand variant on a kbd)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
keys |
string | string[] |
required | Single key ("Esc") or combo array (["Cmd", "K"]). |
mac |
boolean | undefined |
auto | Force Mac (true) or Windows (false) symbols. Auto-detects when omitted. |
size |
'sm' | 'md' | 'lg' |
'md' |
Cap size and font scale. |
separator |
string |
'+' |
Joiner between keys. Use ' β ' for sequential combos like G β S. Ignored if keys is a string. |
ariaLabel |
string |
auto | Override the SR-announced label. Default is keys joined by " plus ". |
class |
string |
'' |
Extra classes on the wrapper. |
Edge Cases
| Situation | Behaviour |
|---|---|
| User on Windows, server rendered for Mac default | First paint shows β; on hydration, Svelte re-renders to Win + K. Brief flicker, no error. To avoid the flicker, pass mac explicitly when SSR'ing. |
keys contains an unknown name (e.g. "F4") |
symbolFor falls through to the default case and returns the key as-is. So ["Cmd", "F4"] renders as β F4. |
keys = [] (empty array) |
Renders an empty <kbd>. Keyboard a11y not affected (the <kbd> is still semantic), but the visual output is just an empty pill. |
separator is a multi-character string |
Rendered as-is between keys. separator=" then " produces β then K. |
User has prefers-color-scheme: dark system preference |
All seven --kbd-* chrome tokens flip via the media query. The whole cap reads on dark surfaces. |
Consumer overrides --kbd-bg-top at :root and uses dark mode |
The system-pref media block writes to .kbd directly with β₯(0,2,0) specificity, beating the inherited :root token. To override in dark mode, target body .kbd.kbd or your own .dark .kbd.kbd. |
navigator.platform returns an empty string |
The regex doesn't match; isMac returns false; Windows glyphs are used. |
| Component used inside a Tooltip on a button | The <kbd> is inline-flex, so it sits naturally next to the button's text. No layout fights. |
Dependencies
- Svelte 5.x β
$derived.byfor the platform check,$propsfor the typed interface. - Zero external dependencies. Native
<kbd>, scoped CSS, no icon library.
File Structure
src/lib/components/KbdShortcut.svelte # implementation
src/lib/components/KbdShortcut.md # this file (rendered inside ComponentPageShell)
src/lib/components/KbdShortcut.test.ts # vitest unit tests
src/routes/kbdshortcut/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
keys | string | string[] | β | Required. A single key or an array forming a combo. |
mac | boolean | auto-detected | Force Mac glyphs (true) or readable text (false). Auto-detect when omitted. |
size | 'sm' | 'md' | 'lg' | 'md' | Cap font and padding. |
separator | string | '+' | Text rendered between keys. Use " β " for sequential combos. |
ariaLabel | string | auto-generated | Override the auto "Cmd plus K" style aria label. |