GrKbd
Displays a keyboard key or shortcut in a `<kbd>` element.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a key combination is shown — in a hint, in the help, in a menu item;
- the combination depends on the platform —
platformprints⌘on macOS andCtrlon the rest; - there are several keys —
keysassembles the combination with a separator, with no markup by hand; - native semantics is needed — it renders as a
<kbd>tag rather than as a styled<span>.
When to take something else
| Need | Take |
|---|---|
| A status or a tag is shown | GrBadge |
| A hint about the purpose is needed | GrTooltip |
| A fragment of code is shown | the markup of the application: there are no code blocks in the package |
| The combination opens the command palette | GrCommandPalette |
A single key and a combination
<GrKbd>
Esc
</GrKbd>
<GrKbd keys="mod+K" />
<GrKbd :keys="['mod', 'shift', 'P']" />
The slot remains usable for a single key. The keys prop accepts both a string
("mod+shift+K") and an array of tokens — assembling ⌘ + K from two GrKbd and a
<span>+</span> is no longer necessary.
A combination is marked up with nested <kbd> — a device from the HTML specification:
that way it stays keyboard input as a whole rather than a set of neighbouring elements. Only
which of them carries the border depends on the variant.
The variants
variant | The look | When |
|---|---|---|
merged (the default) | a single plate: ⌘K, Ctrl+K | a hint next to a button or in a field — that is how the systems themselves write a combination |
split | a plate per key: ⌘ K | when it is exactly the set of keys that has to be shown, in a keyboard-shortcuts reference for instance |
sequence | G then I | a chord: the keys are pressed one after another rather than together |
<GrKbd keys="mod+K" /> <!-- ⌘K -->
<GrKbd keys="mod+K" variant="split" /> <!-- ⌘ K -->
<GrKbd :keys="['G', 'I']" variant="sequence" />
The separator is computed by itself
separator is not set by default — that is auto:
- in
mergedthe separator is put only after a word:⌘Kand⌘⇧Kon macOS,Ctrl+KandCtrl+Shift+Kon the rest. Within one platform the set is uniform (macOS gives symbols, the rest give words), so the rule “a symbol on the left — glue them” means “write it the way the system does”; - in
splitit is+, insequencea word from the locale (gr.kbd.then).
An explicit separator is stronger than the auto one: separator="" will glue anything,
separator="+" will put a plus even between symbols. The separator is decorative
(aria-hidden).
The dictionary of keys
Tokens set more than the modifiers:
| Token (and aliases) | macOS | The rest |
|---|---|---|
mod | ⌘ | Ctrl |
ctrl, alt, shift | ⌃, ⌥, ⇧ | Ctrl, Alt, Shift |
enter / return | ↩ | Enter |
esc, space, home, end | as a word | as a word |
tab | ⇥ | Tab |
backspace | ⌫ | Backspace |
delete / del | ⌦ | Del |
pageup / pgup, pagedown / pgdn | ⇞, ⇟ | PgUp, PgDn |
up, down, left, right | ↑ ↓ ← → | ↑ ↓ ← → |
The case does not matter, and a glyph is accepted as a token too (↑, ⌘). Writing an arrow
as a literal in the markup is neither necessary nor advisable: a token brings a readable name
with it, while a bare glyph is pronounced by a screen reader as a sign.
The catalogue is not a table in the docs but data: GR_KBD_TOKENS is exported by the
package, the showcase page is built from it, and it feeds the formatter as well. A copy of
the list would diverge from the behaviour at the very first new key.
import { GR_KBD_TOKENS, findKbdToken } from '@feugene/granularity'
const navigation = GR_KBD_TOKENS.filter(spec => spec.group === 'navigation')
const command = findKbdToken('⌘') // the same token as `meta`The platform
The mod token is Cmd on macOS and Ctrl on the other platforms; ctrl, alt and shift
are shown as symbols on Apple too (⌃, ⌥, ⇧) and as words on the rest.
<GrKbd keys="mod+K" /> <!-- detected automatically -->
<GrKbd keys="mod+K" platform="apple" /> <!-- always ⌘ -->
<GrKbd keys="mod+K" platform="other" /> <!-- always Ctrl -->
platform="auto" refines the platform after mounting: navigator in the first render
would diverge from the server HTML and break the hydration. The non-Apple variant therefore
always comes from the server, and the ⌘ appears on the client. If the audience is known in
advance, platform removes even that repaint.
The same mod token is understood by the v-hotkey directive, so the hint and the binding
are written the same way and do not diverge:
<div v-hotkey="{ 'mod+K': openSearch }">
<GrKbd keys="mod+K" />
</div>
The parsing and the normalisation live in components/shared/hotkey.ts — the same module
GrCommandPalette uses to match its own opening combination. A shared module rather than an
import from the directory of the palette: otherwise a GrKbd → GrCommandPalette edge would
appear in the build, and granular doctor would count it as an undeclared dependency.
The screen reader
Symbol keys get a readable name beside the symbol:
<kbd><span aria-hidden="true">⌘</span><span class="sr-only">Command</span></kbd>
Without that a screen reader pronounces ⌘ as “place of interest sign”. Words (Ctrl,
Shift, Enter) are read on their own — no name is added to them, otherwise it would come
out as “Ctrl Control”. The texts of the names are the gr.kbd.* keys.
The sizes
The full scale of the package: xs, sm, md (the default), lg — the height, the minimum
width, the padding, the type size and the gap between the keys change. size is read from
GrConfigProvider.
min-w keeps a single symbol square: without it “K” would be narrower than “Esc”, and a row
of shortcuts would jump.
Playground 3
Loading…
<GrKbd />Install
npm i @feugene/granularityImport
import { GrKbd } from '@feugene/granularity/components/GrKbd'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | "split" | "merged" | "sequence" | undefined | "merged" | `merged` (the default) — the combination as a single plate, the way the systems themselves write it: `⌘K` on macOS, `Ctrl+K` on the rest. `split` — a plate per key. `sequence` — the chord "G then I": the keys are pressed one after another. |
keys | string | string[] | undefined | undefined | The combination: as a string (`"mod+shift+K"`) or as a set of tokens (`['mod', 'K']`). The `mod` token is Cmd on macOS and Ctrl on the rest. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
separator | string | undefined | undefined | The separator between the keys. Unset — auto: in a merged plate the symbols are glued (`⌘K`) and the words are separated with a plus (`Ctrl+K`); in `split` it is a plus, in `sequence` a word from the locale. An empty string leaves only a gap. |
platform | GrKbdPlatform | undefined | "auto" | — |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | A key or a combination instead of the `keys` prop. |
Examples 5
Basic
<script setup lang="ts">
import { GrKbd } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-4 text-sm">
<div class="flex flex-wrap items-center gap-5">
<!-- `mod` сам превращается в Cmd на macOS и в Ctrl на остальных платформах. -->
<GrKbd keys="mod+K" />
<GrKbd keys="mod+shift+P" />
<GrKbd :keys="['ctrl', 'S']" />
<GrKbd>Esc</GrKbd>
</div>
<div class="flex flex-wrap items-center gap-5">
<GrKbd keys="mod+K" size="xs" />
<GrKbd keys="mod+K" size="sm" />
<GrKbd keys="mod+K" size="md" />
<GrKbd keys="mod+K" size="lg" />
</div>
</div>
</template>Hotkey Hints
- НайтиCtrlK
- СохранитьCtrlS
- Палитра командCtrlShiftP
- ОтменитьCtrlZ
- ЗакрытьEscape
<script setup lang="ts">
import { ref } from 'vue'
import type { GrKbdPlatform } from '@feugene/granularity'
import { GrKbd, GrSegmented } from '@feugene/granularity'
const platform = ref<GrKbdPlatform>('auto')
const commands = [
{ label: 'Найти', keys: 'mod+K' },
{ label: 'Сохранить', keys: 'mod+S' },
{ label: 'Палитра команд', keys: 'mod+shift+P' },
{ label: 'Отменить', keys: ['mod', 'Z'] },
{ label: 'Закрыть', keys: 'Esc' },
]
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="platform"
size="sm"
:options="[
{ value: 'auto', label: 'auto' },
{ value: 'apple', label: 'macOS' },
{ value: 'other', label: 'Windows/Linux' },
]"
/>
<ul class="grid gap-1 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-2">
<li
v-for="command in commands"
:key="command.label"
class="flex items-center justify-between gap-6 rounded-xl px-3 py-2 text-sm hover:bg-[var(--gr-muted)]"
>
<span>{{ command.label }}</span>
<GrKbd :keys="command.keys" :platform="platform" size="sm" />
</li>
</ul>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Токен `mod` пишется один раз, а показывается по платформе. Символы (`⌘`, `⇧`) снабжены
скрытым читаемым именем — иначе диктор произносит их как значки.
</div>
</div>
</template>Navbar Search
<script setup lang="ts">
import { ref } from 'vue'
import {
GrAvatar,
GrCommandPalette,
GrKbd,
GrNavbar,
vHotkey,
type GrCommandItem,
} from '@feugene/granularity'
import SearchIcon from '~icons/lucide/search'
const isSearchOpen = ref(false)
const lastPicked = ref<string | null>(null)
/** Токен `mod` один и тот же в привязке и в подсказке: Cmd на macOS, Ctrl на прочих. */
const searchHotkey = {
scope: 'element' as const,
handlers: {
'mod+K': { handler: () => (isSearchOpen.value = true), stopPropagation: true },
},
}
const items: GrCommandItem[] = [
{ id: 'orders', label: 'Заказы', description: 'Список и статусы', shortcut: ['mod', 'O'] },
{ id: 'customers', label: 'Клиенты', description: 'Карточки и сегменты', shortcut: ['mod', 'U'] },
{ id: 'settings', label: 'Настройки', description: 'Оплата, доставка, роли', shortcut: ['mod', ','] },
{ id: 'logs', label: 'Журнал событий', description: 'Аудит действий' },
]
function onSelect(item: GrCommandItem): void {
lastPicked.value = item.label
}
</script>
<template>
<!--
Хоткей ограничен демо (`scope: 'element'`) и гасит всплытие: у самой витрины
⌘K уже занят её поиском, и глобальная привязка открыла бы обе панели сразу.
В приложении scope не нужен — там сочетание слушает окно.
-->
<div
v-hotkey="searchHotkey"
tabindex="0"
class="grid gap-3 rounded-[var(--gr-radius-lg)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--gr-ring)]"
>
<GrNavbar title="Консоль">
<template #center>
<!--
Кнопка-поле: подпись даёт имя, а сочетание рядом декоративно —
диктору его сообщает `aria-keyshortcuts`.
-->
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] px-3 text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-tight)] text-[var(--gr-muted-fg)] transition-colors hover:bg-[var(--gr-bg)]"
aria-haspopup="dialog"
aria-keyshortcuts="Control+K Meta+K"
@click="isSearchOpen = true"
>
<SearchIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
<span>Поиск</span>
<span class="ml-2 inline-flex" aria-hidden="true">
<GrKbd keys="mod+K" size="sm" />
</span>
</button>
</template>
<GrAvatar name="Ирина Петрова" size="sm" />
</GrNavbar>
<p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
Нажмите <GrKbd keys="mod+K" size="sm" />, когда фокус внутри демо, — или кликните по кнопке.
<template v-if="lastPicked">
Выбрано: <strong class="text-[var(--gr-fg)]">{{ lastPicked }}</strong>.
</template>
</p>
<!-- `hotkey: null` — сочетание уже слушает демо, второй слушатель открыл бы панель дважды. -->
<GrCommandPalette
v-model="isSearchOpen"
:items="items"
:hotkey="null"
placeholder="Раздел, действие, документ"
aria-label="Поиск по консоли"
@select="onSelect"
/>
</div>
</template>Variants
merged — по умолчанию — ⌘K на macOS, Ctrl+K на прочих
split — плашка на каждую клавишу
sequence — аккорд: клавиши нажимают одну за другой
<script setup lang="ts">
import { ref } from 'vue'
import type { GrKbdPlatform } from '@feugene/granularity'
import { GrKbd, GrSegmented } from '@feugene/granularity'
const platform = ref<GrKbdPlatform>('auto')
const rows = [
{ title: 'merged — по умолчанию', variant: 'merged' as const, hint: '⌘K на macOS, Ctrl+K на прочих' },
{ title: 'split', variant: 'split' as const, hint: 'плашка на каждую клавишу' },
]
const combos = ['mod+K', 'mod+shift+P', 'ctrl+alt+delete']
</script>
<template>
<div class="grid gap-5 text-sm">
<GrSegmented
v-model="platform"
size="sm"
:options="[
{ value: 'auto', label: 'auto' },
{ value: 'apple', label: 'macOS' },
{ value: 'other', label: 'Windows/Linux' },
]"
/>
<div v-for="row in rows" :key="row.variant" class="grid gap-2">
<p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
{{ row.title }} — {{ row.hint }}
</p>
<div class="flex flex-wrap items-center gap-5">
<GrKbd
v-for="combo in combos"
:key="combo"
:keys="combo"
:variant="row.variant"
:platform="platform"
/>
</div>
</div>
<div class="grid gap-2">
<p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
sequence — аккорд: клавиши нажимают одну за другой
</p>
<div class="flex flex-wrap items-center gap-5">
<GrKbd :keys="['G', 'I']" variant="sequence" :platform="platform" />
<GrKbd :keys="['G', 'P']" variant="sequence" :platform="platform" />
</div>
</div>
</div>
</template>Tokens
Модификаторы
- Ctrl
mod - Meta
metaто же: cmd, command, ⌘ - Ctrl
ctrlто же: control, ⌃ - Alt
altто же: option, ⌥ - Shift
shiftто же: ⇧
Ввод и редактирование
- Enter
enterто же: return, ↵, ↩ - Escape
escто же: escape диктор: escape - Tab
tabто же: ⇥ - Space
space - Backspace
backspaceто же: ⌫ - Del
deleteто же: del, ⌦
Навигация
- Arrow Up
upто же: arrowup, ↑ диктор: arrowUp - Arrow Down
downто же: arrowdown, ↓ диктор: arrowDown - Arrow Left
leftто же: arrowleft, ← диктор: arrowLeft - Arrow Right
rightто же: arrowright, → диктор: arrowRight - Home
home - End
end - PgUp
pageupто же: pgup, ⇞ - PgDn
pagedownто же: pgdn, pgdown, ⇟
Всё остальное компонент показывает как есть: буква приводится к заглавной (K), слово остаётся словом (F5).
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { GrKbdPlatform, GrKbdTokenGroup } from '@feugene/granularity'
import { GR_KBD_TOKENS, GrKbd, GrSegmented } from '@feugene/granularity'
const platform = ref<GrKbdPlatform>('auto')
/**
* `auto` уточняется после монтирования — тем же способом, что и в самом
* компоненте (`navigator` в теле setup разошёлся бы с серверным рендером).
* Без этого колонка «диктор» показывала бы имя не той платформы, чей глиф
* нарисован рядом.
*/
const detectedApple = ref(false)
onMounted(() => {
detectedApple.value = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
})
const isApple = computed(() => (platform.value === 'auto' ? detectedApple.value : platform.value === 'apple'))
const groups: { id: GrKbdTokenGroup, title: string }[] = [
{ id: 'modifier', title: 'Модификаторы' },
{ id: 'editing', title: 'Ввод и редактирование' },
{ id: 'navigation', title: 'Навигация' },
]
/**
* Список приходит из самого пакета (`GR_KBD_TOKENS`), а не переписан руками:
* своя копия разошлась бы с форматтером на первой же новой клавише.
*/
const byGroup = computed(() => groups.map(group => ({
...group,
tokens: GR_KBD_TOKENS.filter(spec => spec.group === group.id),
})))
</script>
<template>
<div class="grid gap-5">
<GrSegmented
v-model="platform"
size="sm"
:options="[
{ value: 'auto', label: 'auto' },
{ value: 'apple', label: 'macOS' },
{ value: 'other', label: 'Windows/Linux' },
]"
/>
<section v-for="group in byGroup" :key="group.id" class="grid gap-2">
<h4 class="text-[length:var(--gr-text-xs)] font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
{{ group.title }}
</h4>
<ul class="grid gap-1 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-2">
<li
v-for="spec in group.tokens"
:key="spec.token"
class="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-xl px-3 py-2 text-[length:var(--gr-text-sm)] hover:bg-[var(--gr-muted)]"
>
<GrKbd :keys="[spec.token]" :platform="platform" size="sm" />
<code class="text-[length:var(--gr-text-xs)] text-[var(--gr-fg)]">{{ spec.token }}</code>
<span
v-if="spec.aliases.length"
class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]"
>
то же: {{ spec.aliases.join(', ') }}
</span>
<!-- Имя произносит диктор вместо глифа; у слов его нет — они читаются сами. -->
<span
v-if="(isApple ? spec.apple : spec.other).name"
class="ml-auto text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]"
>
диктор: {{ (isApple ? spec.apple : spec.other).name }}
</span>
</li>
</ul>
</section>
<p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
Всё остальное компонент показывает как есть: буква приводится к заглавной
(<GrKbd keys="k" :platform="platform" size="sm" />), слово остаётся словом
(<GrKbd :keys="['F5']" :platform="platform" size="sm" />).
</p>
</div>
</template>