A Kotlin DSL for declarative keyboard shortcut handling in Compose Multiplatform (Android, Desktop).
ShortcutBox(
shortcuts = {
Key.S with ctrl press { save() }
Key.K with ctrl andThen Key.P press { openCommandPalette() }
Key.Space with hyper press { spotlight() }
}
) {
MyApp()
}// build.gradle.kts
dependencies {
implementation("io.github.yoursvalentiine:hyperkey:0.1.0-beta03")
}The library has three layers:
| Layer | API | Use case |
|---|---|---|
| Container | ShortcutBox |
Shortcuts for an entire area |
| Modifier | rememberShortcutModifier + onShortcut |
Shortcuts on a single component |
| Trigger types | Chord, Sequence | Simultaneous vs sequential keys |
Wraps any content and intercepts keyboard events within it. Takes a ShortcutScope lambda where all shortcuts are
declared.
ShortcutBox(
modifier = Modifier.fillMaxSize(),
shortcuts = {
Key.S with ctrl press { save() }
Key.Z with ctrl press { undo() }
Key.Z with ctrl + shift press { redo() }
Key.Escape press { closeDialog() }
}
) {
// Normal Box content
EditorContent()
}For attaching shortcuts to a single component. Since onShortcut requires remember internally, use the two-step
pattern:
@Composable
fun MyTextField() {
// Step 1 — create at composable scope
val shortcuts = rememberShortcutModifier {
Key.Enter press { submit() }
Key.Escape press { cancel() }
Key.Tab press { focusNext() }
}
// Step 2 — apply in modifier chain
TextField(
modifier = Modifier
.fillMaxWidth()
.onShortcut(shortcuts)
.padding(16.dp)
)
}Multiple independent shortcut groups on one component:
val navigationShortcuts = rememberShortcutModifier {
Key.K with ctrl andThen Key.S press { saveAll() }
Key.K with ctrl andThen Key.B press { toggleSidebar() }
}
val editingShortcuts = rememberShortcutModifier {
Key.S with ctrl press { save() }
Key.Z with ctrl press { undo() }
}
Box(
modifier = Modifier
.onShortcut(navigationShortcuts) // own sequence progress tracker
.onShortcut(editingShortcuts) // own sequence progress tracker
)Modifiers are top-level objects that combine with +:
ctrl
alt
shift
meta
hyper // expands to ctrl + alt + shift + metaKey.S with ctrl press { }
Key.S with ctrl + shift press { }
Key.S with hyper press { } // ctrl + alt + shift + meta + SA chord fires when all specified keys and modifiers are held at the same time.
// Single key
Key.Escape press { }
// Key + modifier
Key.S with ctrl press { }
Key.Z with ctrl + shift press { }
// Multiple keys + modifier
Key.A + Key.B with ctrl press { }
Key.A + Key.B + Key.C with alt press { }A sequence fires when keys are pressed one after another, in order. The timeout between steps is 2 seconds by default.
// Two steps
Key.K with ctrl andThen Key.S press { saveAll() }
Key.K with ctrl andThen Key.B press { toggleSidebar() }
// Three steps
Key.K with ctrl andThen Key.G andThen Key.G press { goToLine() }
// Mixed modifiers per step
Key.K with ctrl andThen Key.P with shift press { openPalette() }Sequence matching rules:
KeyUpevents between steps are ignored — releasing a key does not reset progress- Autorepeat
KeyDownevents (holding a key) are ignored - Progress resets if a wrong key is pressed at any step
- Progress resets after 2 seconds of inactivity
Key.Enter press { } // fires on KeyDown
Key.Enter up { } // fires on KeyUp
Key.S with ctrl press { }
Key.S with ctrl up { }
Key.K with ctrl andThen Key.S press { }
Key.K with ctrl andThen Key.S up { }By default, keyboard events bubble upward — the innermost focused component handles them first. Events inside
preview { } are dispatched top-down, so a parent ShortcutBox intercepts them before any child.
ShortcutBox(
shortcuts = {
Key.S with ctrl press { save() } // child handles first (normal)
preview {
Key.Escape press { closeModal() } // parent intercepts before child
Key.F10 press { openMenu() }
}
}
) {
ChildContent()
}Shortcut events propagate upward — the innermost ShortcutBox handles matching shortcuts first. If it returns true (
handled), the event stops. Unmatched events bubble to the parent.
ShortcutBox(
shortcuts = {
Key.S with ctrl press { globalSave() } // handles Ctrl+S everywhere
}
) {
ShortcutBox(
shortcuts = {
Key.Escape press { closeModal() } // intercepts Escape here
// Ctrl+S bubbles to parent
}
) {
ModalContent()
}
}// ── Chords ──────────────────────────────────────────────────────
Key.A press { }
Key.A with ctrl press { }
Key.A with ctrl + alt press { }
Key.A with ctrl + alt + shift press { }
Key.A with hyper press { } // ctrl + alt + shift + meta + A
Key.A + Key.B press { } // A and B simultaneously
Key.A + Key.B with ctrl press { }
// ── Sequences ───────────────────────────────────────────────────
Key.A andThen Key.B press { }
Key.A with ctrl andThen Key.B press { }
Key.A with ctrl andThen Key.B with shift press { }
Key.A andThen Key.B andThen Key.C press { }
// ── KeyUp ───────────────────────────────────────────────────────
Key.A up { }
Key.A with ctrl up { }
Key.A andThen Key.B up { }Each rememberShortcutModifier and ShortcutBox owns a ShortcutMatcher instance that lives for the lifetime of the
composable (survives recomposition via remember).
The matcher tracks:
pressedKeys: Set<Key>— currently held keys, for multi-key chordssequenceProgress: Map<KeyShortcut, Int>— current step index per sequence shortcutlastEventTime— for sequence timeout detection
hyper is a virtual modifier that expands to ctrl + alt + shift + meta at match time:
// These are equivalent:
Key.A with hyper press { }
Key.A with ctrl + alt + shift + meta press { }Parts of this code were generated with the assistance of Claude (Anthropic).