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

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 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.json
https://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>
    </>
  )
}