Buttons
Hotkey Bound Button
Кнопка сама держит своё сочетание клавиш: вешает слушатель, показывает ⌘ на macOS и Ctrl на остальных и подсвечивается, когда сочетание сработало.
- button
- hotkey
- keyboard
- shortcut
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/button-025
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «button-025» (Hotkey Bound Button) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/button-025.json
Registry item: https://vibeui.ru/r/button-025.json
Ставится в: components/vibeui/button-025.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Кнопка сама держит своё сочетание клавиш: вешает слушатель, показывает ⌘ на macOS и Ctrl на остальных и подсвечивается, когда сочетание сработало.
Кнопка команды, которая сама слушает своё сочетание клавиш. Она разбирает combo вида «mod+s», подставляет ⌘ на macOS и Ctrl на остальных системах, рисует клавиши тегом kbd и при срабатывании даёт короткую вспышку — видно, что нажатие ушло именно сюда. Пока фокус в поле ввода, сочетание не перехватывается.
## 3. Как использовать
import { Button025 } from "@/components/vibeui/button-025"
<Button025 combo="mod+s" onAction={saveDraft} label="Сохранить черновик" />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- слушатель на window вместе с видимой подсказкой: подпись и реальное поведение задаются одним пропом combo и не могут разойтись
- подстановку ⌘ на macOS и Ctrl на остальных: подсказка с чужим модификатором хуже, чем никакой
- чтение платформы через useSyncExternalStore с серверным снимком false — при вычислении прямо в рендере сервер и клиент нарисуют разные клавиши и гидратация упадёт
- пропуск события, когда фокус в input, textarea, select или contenteditable: иначе сочетание крадёт ввод
- вспышку при срабатывании — без неё непонятно, сработало ли нажатие
- обработчик в ref: слушатель подписывается один раз, а не на каждый рендер
## 6. Можно менять
- подпись — проп label
- сочетание — проп combo: «mod» это Cmd на macOS и Ctrl на остальных
- действие — проп onAction, оно же вызывается обычным кликом
- акцентный цвет — проп accent
- внешние отступы через className
## 7. Правила
- Не вешай то же сочетание где-то ещё: два слушателя на одном combo сработают оба.
- Проверь, что combo не перекрывает системное сочетание браузера — mod+w или mod+t перехватить не выйдет.
- Кнопка вызывает onAction, а не onClick: обычный onClick тоже сработает, но действие лучше держать в одном месте.
- Не выноси CSS-переменные в globals.css: компонент должен оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
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, чтобы слушатель не переподписывался на каждый рендер.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"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>
</>
)
}