= {
+ render: args => (
+
+ Press to toggle between write and preview modes.
+
+ ),
+ args: {
+ keys: 'Mod+Shift+P',
+ format: 'full',
+ },
+}
+
+export const TextInputExample: StoryObj = {
+ render: args => (
+
+ Search
+ } placeholder="Search" />
+
+ ),
+ args: {keys: '/'},
+ name: 'TextInput',
+}
diff --git a/packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx b/packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx
new file mode 100644
index 00000000000..75448e75e41
--- /dev/null
+++ b/packages/react/src/KeybindingHint/KeybindingHint.features.stories.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import type {Meta, StoryObj} from '@storybook/react'
+import {KeybindingHint, type KeybindingHintProps} from '.'
+import Box from '../Box'
+
+export default {
+ title: 'Drafts/Components/KeybindingHint/Features',
+ component: KeybindingHint,
+} satisfies Meta
+
+const chord = 'Mod+Shift+K'
+
+export const Condensed = {args: {keys: chord}}
+
+export const Full = {args: {keys: chord, format: 'full'}}
+
+const sequence = 'Mod+x y z'
+
+export const SequenceCondensed = {args: {keys: sequence}}
+
+export const SequenceFull = {args: {keys: sequence, format: 'full'}}
+
+export const OnEmphasis: StoryObj = {
+ render: args => (
+
+
+
+ ),
+ args: {keys: chord, variant: 'onEmphasis'},
+}
diff --git a/packages/react/src/KeybindingHint/KeybindingHint.stories.tsx b/packages/react/src/KeybindingHint/KeybindingHint.stories.tsx
new file mode 100644
index 00000000000..2452f5be70e
--- /dev/null
+++ b/packages/react/src/KeybindingHint/KeybindingHint.stories.tsx
@@ -0,0 +1,9 @@
+import type {Meta} from '@storybook/react'
+import {KeybindingHint} from './KeybindingHint'
+
+export default {
+ title: 'Drafts/Components/KeybindingHint',
+ component: KeybindingHint,
+} satisfies Meta
+
+export const Default = {args: {keys: 'Mod+Shift+K'}}
diff --git a/packages/react/src/KeybindingHint/KeybindingHint.tsx b/packages/react/src/KeybindingHint/KeybindingHint.tsx
new file mode 100644
index 00000000000..82338b748ca
--- /dev/null
+++ b/packages/react/src/KeybindingHint/KeybindingHint.tsx
@@ -0,0 +1,48 @@
+import React, {type ReactNode} from 'react'
+import {memo} from 'react'
+import Text from '../Text'
+import type {KeybindingHintProps} from './props'
+import {accessibleSequenceString, Sequence} from './components/Sequence'
+
+/** `kbd` element with style resets. */
+const Kbd = ({children}: {children: ReactNode}) => (
+
+ {children}
+
+)
+
+/** Indicates the presence of an available keybinding. */
+// KeybindingHint is a good candidate for memoizing since props will rarely change
+export const KeybindingHint = memo((props: KeybindingHintProps) => (
+
+
+
+))
+KeybindingHint.displayName = 'KeybindingHint'
+
+/**
+ * AVOID: `KeybindingHint` is nearly always sufficient for providing both visible and accessible keyboard hints, and
+ * will result in a good screen reader experience when used as the target for `aria-describedby` and `aria-labelledby`.
+ * However, there may be cases where we need a plain string version, such as when building `aria-label` or
+ * `aria-description`. In that case, this plain string builder can be used instead.
+ *
+ * NOTE that this string should _only_ be used when building `aria-label` or `aria-description` props (never rendered
+ * visibly) and should nearly always also be paired with a visible hint for sighted users.
+ */
+export const getAccessibleKeybindingHintString = accessibleSequenceString
diff --git a/packages/react/src/KeybindingHint/components/Chord.tsx b/packages/react/src/KeybindingHint/components/Chord.tsx
new file mode 100644
index 00000000000..d12ce8f44ff
--- /dev/null
+++ b/packages/react/src/KeybindingHint/components/Chord.tsx
@@ -0,0 +1,70 @@
+import React, {Fragment} from 'react'
+import Text from '../../Text'
+import type {KeybindingHintProps} from '../props'
+import {Key} from './Key'
+import {accessibleKeyName} from '../key-names'
+
+/**
+ * Consistent sort order for modifier keys. There should never be more than one non-modifier
+ * key in a chord, so we don't need to worry about sorting those - we just put them at
+ * the end.
+ */
+const keySortPriorities: Partial> = {
+ control: 1,
+ meta: 2,
+ alt: 3,
+ option: 4,
+ shift: 5,
+ function: 6,
+}
+
+const keySortPriority = (priority: string) => keySortPriorities[priority] ?? Infinity
+
+const compareLowercaseKeys = (a: string, b: string) => keySortPriority(a) - keySortPriority(b)
+
+/** Split and sort the chord keys in standard order. */
+const splitChord = (chord: string) =>
+ chord
+ .split('+')
+ .map(k => k.toLowerCase())
+ .sort(compareLowercaseKeys)
+
+export const Chord = ({keys, format = 'condensed', variant = 'normal'}: KeybindingHintProps) => (
+
+ {splitChord(keys).map((k, i) => (
+
+ {i > 0 && format === 'full' ? (
+ + // hiding the plus sign helps screen readers be more concise
+ ) : (
+ ' ' // space is nonvisual due to flex layout but critical for labelling / screen readers
+ )}
+
+
+
+ ))}
+
+)
+
+/** Plain string version of `Chord` for use in `aria` string attributes. */
+export const accessibleChordString = (chord: string, isMacOS: boolean) =>
+ splitChord(chord)
+ .map(key => accessibleKeyName(key, isMacOS))
+ .join(' ')
diff --git a/packages/react/src/KeybindingHint/components/Key.tsx b/packages/react/src/KeybindingHint/components/Key.tsx
new file mode 100644
index 00000000000..eb803924f09
--- /dev/null
+++ b/packages/react/src/KeybindingHint/components/Key.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import VisuallyHidden from '../../_VisuallyHidden'
+import {accessibleKeyName, condensedKeyName, fullKeyName} from '../key-names'
+import type {KeybindingHintFormat} from '../props'
+import {useIsMacOS} from '../../hooks/useIsMacOS'
+
+interface KeyProps {
+ name: string
+ format: KeybindingHintFormat
+}
+
+/** Renders a single key with accessible alternative text. */
+export const Key = ({name, format}: KeyProps) => {
+ const isMacOS = useIsMacOS()
+
+ return (
+ <>
+ {accessibleKeyName(name, isMacOS)}
+ {format === 'condensed' ? condensedKeyName(name, isMacOS) : fullKeyName(name, isMacOS)}
+ >
+ )
+}
diff --git a/packages/react/src/KeybindingHint/components/Sequence.tsx b/packages/react/src/KeybindingHint/components/Sequence.tsx
new file mode 100644
index 00000000000..aaa4ba00f0f
--- /dev/null
+++ b/packages/react/src/KeybindingHint/components/Sequence.tsx
@@ -0,0 +1,27 @@
+import React, {Fragment} from 'react'
+import type {KeybindingHintProps} from '../props'
+import VisuallyHidden from '../../_VisuallyHidden'
+import {accessibleChordString, Chord} from './Chord'
+
+const splitSequence = (sequence: string) => sequence.split(' ')
+
+export const Sequence = ({keys, format = 'condensed', variant = 'normal'}: KeybindingHintProps) =>
+ splitSequence(keys).map((c, i) => (
+
+ {
+ // Since we audibly separate individual keys in chord with space, we need some other separator for chords in a sequence
+ i > 0 && (
+ <>
+ then{' '}
+ >
+ )
+ }
+
+
+ ))
+
+/** Plain string version of `Sequence` for use in `aria` string attributes. */
+export const accessibleSequenceString = (sequence: string, isMacOS: boolean) =>
+ splitSequence(sequence)
+ .map(chord => accessibleChordString(chord, isMacOS))
+ .join(' then ')
diff --git a/packages/react/src/KeybindingHint/index.ts b/packages/react/src/KeybindingHint/index.ts
new file mode 100644
index 00000000000..dc5f4cb31d5
--- /dev/null
+++ b/packages/react/src/KeybindingHint/index.ts
@@ -0,0 +1,3 @@
+export * from './KeybindingHint'
+
+export type {KeybindingHintProps} from './props'
diff --git a/packages/react/src/KeybindingHint/key-names.ts b/packages/react/src/KeybindingHint/key-names.ts
new file mode 100644
index 00000000000..de5eb5e95bb
--- /dev/null
+++ b/packages/react/src/KeybindingHint/key-names.ts
@@ -0,0 +1,113 @@
+/** Converts the first character of the string to upper case and the remaining to lower case. */
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') + rest.join('').toLowerCase()
+
+// In the below records, we don't intend to cover every single possible key - only those that
+// would be realistically used in shortcuts. For example, the Pause/Break key is not necessary
+// because it is not found on many keyboards.
+
+/**
+ * Short-form iconic versions of keys. These should be intuitive (not archaic) and match icons on keyboards.
+ */
+export const condensedKeyName = (key: string, isMacOS: boolean) =>
+ ({
+ alt: isMacOS ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key
+ control: '⌃',
+ shift: '⇧',
+ meta: isMacOS ? '⌘' : 'Win',
+ mod: isMacOS ? '⌘' : '⌃',
+ pageup: 'PgUp',
+ pagedown: 'PgDn',
+ arrowup: '↑',
+ arrowdown: '↓',
+ arrowleft: '←',
+ arrowright: '→',
+ plus: '+', // needed to allow +-separated chords
+ backspace: '⌫',
+ delete: 'Del',
+ space: '␣', // needed to allow space-separated sequences
+ tab: '⇥',
+ enter: '⏎',
+ escape: 'Esc',
+ function: 'Fn',
+ capslock: 'CapsLock',
+ insert: 'Ins',
+ printscreen: 'PrtScn',
+ })[key] ?? capitalize(key)
+
+/**
+ * Specific key displays for 'full' format. We still do show some icons (ie punctuation)
+ * because that's more intuitive, but for the rest of keys we show the standard key name.
+ */
+export const fullKeyName = (key: string, isMacOS: boolean) =>
+ ({
+ alt: isMacOS ? 'Option' : 'Alt',
+ mod: isMacOS ? 'Command' : 'Control',
+ '+': 'Plus',
+ pageup: 'Page Up',
+ pagedown: 'Page Down',
+ arrowup: 'Up Arrow',
+ arrowdown: 'Down Arrow',
+ arrowleft: 'Left Arrow',
+ arrowright: 'Right Arrow',
+ capslock: 'Caps Lock',
+ printscreen: 'Print Screen',
+ })[key] ?? capitalize(key)
+
+/**
+ * Accessible key names intended to be read by a screen reader. This prevents screen
+ * readers from expressing punctuation in speech, ie, reading a long pause instead of the
+ * word "period".
+ */
+export const accessibleKeyName = (key: string, isMacOS: boolean) =>
+ ({
+ alt: isMacOS ? 'option' : 'alt',
+ meta: isMacOS ? 'command' : 'Windows',
+ mod: isMacOS ? 'command' : 'control',
+ // Screen readers may not be able to pronounce concatenated words - this provides a better experience
+ pageup: 'page up',
+ pagedown: 'page down',
+ arrowup: 'up arrow',
+ arrowdown: 'down arrow',
+ arrowleft: 'left arrow',
+ arrowright: 'right arrow',
+ capslock: 'caps lock',
+ printscreen: 'print screen',
+ // We don't need to represent _every_ symbol - only those found on standard keyboards.
+ // Other symbols should be avoided as keyboard shortcuts anyway.
+ // These should match the colloqiual names of the keys, not the names of the symbols. Ie,
+ // "Equals" not "Equal Sign", "Dash" not "Minus", "Period" not "Dot", etc.
+ '`': 'backtick',
+ '~': 'tilde',
+ '!': 'exclamation point',
+ '@': 'at',
+ '#': 'hash',
+ $: 'dollar sign',
+ '%': 'percent',
+ '^': 'caret',
+ '&': 'ampersand',
+ '*': 'asterisk',
+ '(': 'left parenthesis',
+ ')': 'right parenthesis',
+ _: 'underscore',
+ '-': 'dash',
+ '+': 'plus',
+ '=': 'equals',
+ '[': 'left bracket',
+ '{': 'left curly brace',
+ ']': 'right bracket',
+ '}': 'right curly brace',
+ '\\': 'backslash',
+ '|': 'pipe',
+ ';': 'semicolon',
+ ':': 'colon',
+ "'": 'single quote',
+ '"': 'double quote',
+ ',': 'comma',
+ '<': 'left angle bracket',
+ '.': 'period',
+ '>': 'right angle bracket',
+ '/': 'forward slash',
+ '?': 'question mark',
+ ' ': 'space',
+ })[key] ?? key.toLowerCase()
diff --git a/packages/react/src/KeybindingHint/props.ts b/packages/react/src/KeybindingHint/props.ts
new file mode 100644
index 00000000000..72584086435
--- /dev/null
+++ b/packages/react/src/KeybindingHint/props.ts
@@ -0,0 +1,30 @@
+export type KeybindingHintFormat = 'condensed' | 'full'
+
+export type KeybindingHintVariant = 'normal' | 'onEmphasis'
+
+export interface KeybindingHintProps {
+ /**
+ * The keys involved in this keybinding. These should be the full names of the keys as would
+ * be returned by `KeyboardEvent.key` (e.g. "Control", "Shift", "ArrowUp", "a", etc.).
+ *
+ * Combine keys with the "+" character to form chords. To represent the "+" key, use "Plus".
+ *
+ * Combine chords/keys with " " to form sequences that should be pressed one after the other. For example, "a b"
+ * represents "a then b". To represent the " " key, use "Space".
+ *
+ * The fake key name "Mod" can be used to represent "Command" on macOS and "Control" on other platforms.
+ *
+ * See https://github.com/github/hotkey for format details.
+ */
+ keys: string
+ /**
+ * Control the display format. Condensed is most useful in menus and tooltips, while
+ * the full form is better for prose.
+ * @default "condensed"
+ */
+ format?: KeybindingHintFormat
+ /**
+ * Set to `onEmphasis` for display on emphasis colors.
+ */
+ variant?: KeybindingHintVariant
+}
diff --git a/packages/react/src/__tests__/KeybindingHint.test.tsx b/packages/react/src/__tests__/KeybindingHint.test.tsx
new file mode 100644
index 00000000000..79281964828
--- /dev/null
+++ b/packages/react/src/__tests__/KeybindingHint.test.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import {render, screen} from '@testing-library/react'
+
+import {KeybindingHint, getAccessibleKeybindingHintString} from '../KeybindingHint'
+
+describe('KeybindingHint', () => {
+ it('renders condensed keys by default', () => {
+ render()
+ for (const icon of ['⇧', '⌃', 'Fn', 'PgUp']) {
+ const el = screen.getByText(icon)
+ expect(el).toBeVisible()
+ expect(el).toHaveAttribute('aria-hidden')
+ }
+ })
+
+ it('renders accessible key descriptions', () => {
+ render()
+ for (const name of ['control', 'shift', 'left curly brace']) {
+ const el = screen.getByText(name)
+ expect(el).toBeInTheDocument()
+ expect(el).not.toHaveAttribute('aria-hidden')
+ }
+ })
+
+ it('renders key names in full format', () => {
+ render()
+ for (const name of ['Shift', 'Control', 'Function', 'Up Arrow']) {
+ const el = screen.getByText(name)
+ expect(el).toBeVisible()
+ expect(el).toHaveAttribute('aria-hidden')
+ }
+ })
+
+ it('sorts modifier keys', () => {
+ render()
+ const namesInOrder = ['Control', 'Shift', 'Function', 'Page Up']
+ const names = screen.getAllByText(text => namesInOrder.includes(text)).map(el => el.textContent)
+ expect(names).toEqual(namesInOrder)
+ })
+
+ it('capitalizes other keys', () => {
+ render()
+ for (const key of ['⌃', 'A']) expect(screen.getByText(key)).toBeInTheDocument()
+ })
+
+ it.each([
+ ['Plus', '+'],
+ ['Space', '␣'],
+ ])('renders %s as symbol in condensed mode', (name, symbol) => {
+ render()
+ expect(screen.getByText(symbol)).toBeInTheDocument()
+ })
+
+ it.each(['Plus', 'Space'])('renders %s as name in full format', name => {
+ render()
+ expect(screen.getByText(name)).toBeInTheDocument()
+ })
+
+ it('does not render plus signs in condensed mode', () => {
+ render()
+ expect(screen.queryByText('+')).not.toBeInTheDocument()
+ })
+
+ it('renders plus signs between keys in full format', () => {
+ render()
+ const plus = screen.getByText('+')
+ expect(plus).toBeVisible()
+ expect(plus).toHaveAttribute('aria-hidden')
+ })
+
+ it('renders sequences separated by hidden "then"', () => {
+ render()
+ const el = screen.getByText('then')
+ expect(el).toBeInTheDocument()
+ expect(el).not.toHaveAttribute('aria-hidden')
+ })
+})
+
+describe('getAccessibleKeybindingHintString', () => {
+ it('returns full readable key names', () =>
+ expect(getAccessibleKeybindingHintString('{', false)).toBe('left curly brace'))
+
+ it('joins keys in a chord with space', () =>
+ expect(getAccessibleKeybindingHintString('Command+U', false)).toBe('command u'))
+
+ it('sorts modifiers in standard order', () =>
+ expect(getAccessibleKeybindingHintString('Alt+Shift+Command+%', false)).toBe('alt shift command percent'))
+
+ it('joins chords in a sequence with "then"', () =>
+ expect(getAccessibleKeybindingHintString('Alt+9 x y', false)).toBe('alt 9 then x then y'))
+
+ it('returns "command" for "mod" on MacOS', () =>
+ expect(getAccessibleKeybindingHintString('Mod+x', true)).toBe('command x'))
+
+ it('returns "control" for "mod" on non-MacOS', () =>
+ expect(getAccessibleKeybindingHintString('Mod+x', false)).toBe('control x'))
+})
diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
index fc800f7be28..e32e439a90a 100644
--- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -289,6 +289,7 @@ exports[`@primer/react/drafts should not update exports without a semver change
"type Emoji",
"type FileType",
"type FileUploadResult",
+ "getAccessibleKeybindingHintString",
"Hidden",
"type HiddenProps",
"InlineAutocomplete",
@@ -296,6 +297,8 @@ exports[`@primer/react/drafts should not update exports without a semver change
"InlineMessage",
"type InlineMessageProps",
"type InteractiveMarkdownViewerProps",
+ "KeybindingHint",
+ "type KeybindingHintProps",
"MarkdownEditor",
"type MarkdownEditorHandle",
"type MarkdownEditorProps",
@@ -407,6 +410,7 @@ exports[`@primer/react/experimental should not update exports without a semver c
"type FileUploadResult",
"FilteredActionList",
"type FilteredActionListProps",
+ "getAccessibleKeybindingHintString",
"Hidden",
"type HiddenProps",
"InlineAutocomplete",
@@ -414,6 +418,8 @@ exports[`@primer/react/experimental should not update exports without a semver c
"InlineMessage",
"type InlineMessageProps",
"type InteractiveMarkdownViewerProps",
+ "KeybindingHint",
+ "type KeybindingHintProps",
"MarkdownEditor",
"type MarkdownEditorHandle",
"type MarkdownEditorProps",
diff --git a/packages/react/src/drafts/index.ts b/packages/react/src/drafts/index.ts
index 16a78befb4a..c57847c7ef1 100644
--- a/packages/react/src/drafts/index.ts
+++ b/packages/react/src/drafts/index.ts
@@ -85,3 +85,5 @@ export {UnderlinePanels} from './UnderlinePanels'
export type {UnderlinePanelsProps, UnderlinePanelsTabProps, UnderlinePanelsPanelProps} from './UnderlinePanels'
export {SkeletonBox, SkeletonText, SkeletonAvatar} from './Skeleton'
+
+export * from '../KeybindingHint'
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts
index 01d1d97c4aa..d8259f5fd96 100644
--- a/packages/react/src/hooks/index.ts
+++ b/packages/react/src/hooks/index.ts
@@ -15,3 +15,4 @@ export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'
export {useMnemonics} from './useMnemonics'
export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
export {useId} from './useId'
+export {useIsMacOS} from './useIsMacOS'
diff --git a/packages/react/src/hooks/useIsMacOS.ts b/packages/react/src/hooks/useIsMacOS.ts
new file mode 100644
index 00000000000..d4982ee7fe8
--- /dev/null
+++ b/packages/react/src/hooks/useIsMacOS.ts
@@ -0,0 +1,15 @@
+import {isMacOS as ssrUnsafeIsMacOS} from '@primer/behaviors/utils'
+import {useEffect, useState} from 'react'
+import {canUseDOM} from '../utils/environment'
+/**
+ * SSR-safe hook for determining if the current platform is MacOS. When rendering
+ * server-side, will default to non-MacOS and then re-render in an effect if the
+ * client turns out to be a MacOS device.
+ */
+export function useIsMacOS() {
+ const [isMacOS, setIsMacOS] = useState(() => (canUseDOM ? ssrUnsafeIsMacOS() : false))
+
+ useEffect(() => setIsMacOS(ssrUnsafeIsMacOS()), [])
+
+ return isMacOS
+}