Buttons
Hotkey Bound Button
The button owns its shortcut: it binds the listener, prints ⌘ on macOS and Ctrl elsewhere, and flashes when the combination actually fires.
- button
- hotkey
- keyboard
- shortcut
Preview
Use it with AI
- 1. Copy the link.
- 2. Write to your agent in your own words and drop the link into the sentence.
- 3. The agent opens the link and installs the component from the registry.
put this in the header: https://vibeui.ru/c/button-025?lang=en
Show the full instructions
What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.
# Install and place "button-025" (Hotkey Bound Button) from VibeUI
## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/button-025.json
Registry item: https://vibeui.ru/r/button-025.json
Installs to: components/vibeui/button-025.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.
Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.
## 2. What it is
The button owns its shortcut: it binds the listener, prints ⌘ on macOS and Ctrl elsewhere, and flashes when the combination actually fires.
A command button that listens for its own shortcut. It parses a combo like mod+s, prints ⌘ on macOS and Ctrl elsewhere, draws the keys as kbd elements and flashes briefly when it fires, so you can see the keystroke landed here. While the focus is inside a text field the shortcut is not intercepted.
## 3. How to use it
import { Button025 } from "@/components/vibeui/button-025"
<Button025 combo="mod+s" onAction={saveDraft} label="Save draft" />
Read the installed file for the full prop list.
## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.
Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)
## 5. Keep exactly as installed
- the window listener together with the visible hint: the caption and the real behaviour come from the one combo prop and cannot drift apart
- the ⌘ on macOS and Ctrl elsewhere substitution: a hint with the wrong modifier is worse than no hint
- reading the platform through useSyncExternalStore with a false server snapshot — computing it during render would make server and client draw different keys and break hydration
- skipping the event while the focus is in an input, textarea, select or contenteditable: otherwise the shortcut steals typing
- the flash on firing — without it there is no way to tell the keystroke worked
- the handler kept in a ref: the listener subscribes once instead of on every render
## 6. You may change
- the caption through the label prop
- the shortcut through the combo prop: mod means Cmd on macOS and Ctrl elsewhere
- the action through the onAction prop, which a plain click fires as well
- the accent colour through the accent prop
- outer spacing through className
## 7. Rules
- Do not bind the same combination elsewhere: two listeners on one combo both fire.
- Check that the combo does not clash with a browser shortcut — mod+w or mod+t cannot be intercepted.
- The button calls onAction rather than onClick: a plain onClick still works, but the action is better kept in one place.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.
## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.For developers
npx shadcn@latest add https://vibeui.ru/r/button-025.jsonhttps://vibeui.ru/r/button-025.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-button-025-*. Клиентский: слушатель keydown висит на window, combo вида «mod+s» разбирается на модификаторы и основную клавишу. Платформа читается через useSyncExternalStore: серверный снимок всегда false, поэтому разметка сервера и клиента совпадает, а после гидратации клавиши перерисовываются под систему. Пока фокус в поле ввода или contenteditable, сочетание не перехватывается. Обработчик держится в ref, чтобы слушатель не переподписывался на каждый рендер.
Component source
The same file your agent installs. Here in case you would rather copy it by hand.
"use client"
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Button025Props = Omit<
ComponentPropsWithoutRef<"button">,
"children"
> & {
label?: string
/** Сочетание: «mod» — Cmd на macOS и Ctrl на остальных. Например «mod+s». */
combo?: string
onAction?: () => void
accent?: string
}
// Идея компонента: кнопка сама держит своё сочетание клавиш. Она не просто
// рисует подсказку, а вешает слушатель на window, разбирает combo вида
// «mod+s», подставляет ⌘ на macOS и Ctrl на остальных системах и подсвечивает
// себя при срабатывании — чтобы было видно, что нажатие ушло именно сюда.
// Пока фокус в поле ввода, сочетание не перехватывается.
const STYLES = `
:where([data-vibeui-block="button-025"]){
--vibeui-button-025-accent:oklch(0.52 0.16 285);
--vibeui-button-025-fg:oklch(0.99 0.01 285);
--vibeui-button-025-radius:0.625rem;
--vibeui-button-025-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-button-025-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
}
[data-vibeui-block="button-025"]{
appearance:none;border:0;cursor:pointer;
display:inline-flex;align-items:center;gap:0.75rem;
height:2.5rem;padding:0 0.5rem 0 1rem;box-sizing:border-box;
border-radius:var(--vibeui-button-025-radius);
background:var(--vibeui-button-025-accent);color:var(--vibeui-button-025-fg);
font-family:var(--vibeui-button-025-font);font-size:0.875rem;font-weight:650;line-height:1;
transition:filter .16s ease,box-shadow .24s ease;
}
[data-vibeui-block="button-025"]:hover{filter:brightness(0.96)}
[data-vibeui-block="button-025"]:focus-visible{outline:2px solid var(--vibeui-button-025-accent);outline-offset:2px}
/* Вспышка: видно, что сочетание сработало именно на этой кнопке. */
[data-vibeui-block="button-025"][data-flash="true"]{
animation:vibeui-button-025-flash .45s ease-out;
}
[data-vibeui-block="button-025"] [data-part="keys"]{display:inline-flex;align-items:center;gap:0.1875rem}
[data-vibeui-block="button-025"] kbd{
display:inline-flex;align-items:center;justify-content:center;
min-width:1.375rem;height:1.5rem;padding:0 0.3125rem;box-sizing:border-box;
border-radius:0.375rem;
background:oklch(1 0 0 / 18%);
box-shadow:inset 0 0 0 1px oklch(1 0 0 / 22%);
font-family:var(--vibeui-button-025-mono);font-size:0.75rem;font-weight:600;line-height:1;
color:inherit;
}
@keyframes vibeui-button-025-flash{
0%{box-shadow:0 0 0 0 oklch(1 0 0 / 55%)}
100%{box-shadow:0 0 0 0.75rem oklch(1 0 0 / 0%)}
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="button-025"],[data-vibeui-block="button-025"] *{animation:none!important;transition:none!important}}
`
const MAC_KEYS: Record<string, string> = {
mod: "⌘",
shift: "⇧",
alt: "⌥",
ctrl: "⌃",
}
const PC_KEYS: Record<string, string> = {
mod: "Ctrl",
shift: "Shift",
alt: "Alt",
ctrl: "Ctrl",
}
const subscribe = () => () => {}
const isMac = () => /mac|iphone|ipad/i.test(navigator.userAgent)
function parts(combo: string) {
return combo
.toLowerCase()
.split("+")
.map((part) => part.trim())
.filter(Boolean)
}
/**
* Кнопка, которая сама слушает своё сочетание клавиш и показывает его в kbd.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Button025({
label = "Сохранить черновик",
combo = "mod+s",
onAction,
accent,
type = "button",
className,
style,
...props
}: Button025Props) {
// Платформа читается через useSyncExternalStore: на сервере снимок всегда
// false, поэтому разметка совпадает и гидратация не падает, а после неё
// клавиши перерисовываются под систему.
const mac = useSyncExternalStore(subscribe, isMac, () => false)
const [flash, setFlash] = useState(false)
const handler = useRef(onAction)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
handler.current = onAction
}, [onAction])
useEffect(() => {
const keys = parts(combo)
const main = keys[keys.length - 1] ?? ""
const onKeyDown = (event: globalThis.KeyboardEvent) => {
const target = event.target as HTMLElement | null
if (
target?.closest("input, textarea, select, [contenteditable='true']")
) {
return
}
const mod = mac ? event.metaKey : event.ctrlKey
if (keys.includes("mod") !== mod) return
if (keys.includes("shift") !== event.shiftKey) return
if (keys.includes("alt") !== event.altKey) return
if (event.key.toLowerCase() !== main) return
event.preventDefault()
handler.current?.()
setFlash(true)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => setFlash(false), 450)
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
if (timer.current) clearTimeout(timer.current)
}
}, [combo, mac])
const palette = {
...(accent ? { "--vibeui-button-025-accent": accent } : null),
...style,
} as CSSProperties
const map = mac ? MAC_KEYS : PC_KEYS
const shown = parts(combo).map((part) => map[part] ?? part.toUpperCase())
return (
<>
<style href="vibeui-button-025" precedence="medium">
{STYLES}
</style>
<button
{...props}
type={type}
data-vibeui-block="button-025"
data-flash={String(flash)}
className={className}
style={palette}
aria-keyshortcuts={parts(combo)
.map((part) => (part === "mod" ? (mac ? "Meta" : "Control") : part))
.join("+")}
onClick={() => handler.current?.()}
>
{label}
<span data-part="keys" aria-hidden="true">
{shown.map((key) => (
<kbd key={key}>{key}</kbd>
))}
</span>
</button>
</>
)
}