Buttons
Submit Once
A submit button that cannot be double-clicked: it locks while working and keeps its width as the label changes.
- button
- submit
- async
- loading
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-017?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-017" (Submit Once) 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-017.json
Registry item: https://vibeui.ru/r/button-017.json
Installs to: components/vibeui/button-017.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
A submit button that cannot be double-clicked: it locks while working and keeps its width as the label changes.
A submit button with three states: idle, working with a ring and a lock, and done with a tick. A second click is impossible. Zero dependencies, one file.
## 3. How to use it
import { Button017 } from "@/components/vibeui/button-017"
<Button017 label="Send request" action={() => api.send(form)} />
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 local --vibeui-button-017-* palette — do not swap it for your theme tokens
- the lock while working: a double click is the most common cause of duplicate records
- aria-busy and aria-live: progress has to be heard, not only seen
- the min-width set by the longest label — otherwise the button jumps
- the early return in the handler: disabled does not stop a programmatic call
- the prefers-reduced-motion rule for the ring
## 6. You may change
- label, pendingLabel and doneLabel
- action — what happens on click
- the accent through the accent prop
- how long the result stays
## 7. Rules
- The default action is a showcase delay: pass a real request in a project.
- Errors are not rendered: wrap your action in try/catch and decide what to show.
- A client-side lock does not replace server-side deduplication: you still need an idempotency key.
- 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-017.jsonhttps://vibeui.ru/r/button-017.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-button-017-*. Клиентский: "use client". Состояние — idle, pending или done; в pending кнопка disabled и aria-busy, повторный вызов отсекается ранним возвратом. Минимальная ширина задана по самой длинной подписи, поэтому смена текста не дёргает раскладку. Таймер возврата снимается при размонтировании.
Component source
The same file your agent installs. Here in case you would rather copy it by hand.
"use client"
import { useEffect, useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Button017Props = Omit<
ComponentPropsWithoutRef<"button">,
"children" | "onClick"
> & {
label?: string
pendingLabel?: string
doneLabel?: string
/** Что делаем по нажатию. По умолчанию — задержка для витрины. */
action?: () => Promise<void>
accent?: string
}
// Идея компонента: кнопка отправки, которую нельзя нажать дважды. Повторный
// клик по «Отправить» — самая частая причина дублей в базе, поэтому кнопка
// блокируется на время работы и объявляет ход дела через aria-live. Ширина
// зафиксирована по самой длинной подписи: смена текста не должна её дёргать.
const STYLES = `
:where([data-vibeui-block="button-017"]){
--vibeui-button-017-accent:oklch(0.55 0.17 265);
--vibeui-button-017-fg:oklch(0.99 0.01 265);
--vibeui-button-017-done:oklch(0.55 0.15 152);
--vibeui-button-017-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="button-017"]{
appearance:none;border:0;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;gap:0.4375rem;
/* Ширина по самой длинной подписи: смена текста не дёргает раскладку. */
min-width:10.5rem;height:2.5rem;padding:0 1rem;box-sizing:border-box;
border-radius:0.625rem;
background:var(--vibeui-button-017-accent);color:var(--vibeui-button-017-fg);
font-family:var(--vibeui-button-017-font);font-size:0.875rem;font-weight:650;line-height:1;
}
[data-vibeui-block="button-017"]:hover:not(:disabled){filter:brightness(0.96)}
[data-vibeui-block="button-017"]:focus-visible{outline:2px solid var(--vibeui-button-017-accent);outline-offset:2px}
[data-vibeui-block="button-017"]:disabled{cursor:progress;opacity:.85}
[data-vibeui-block="button-017"][data-state="done"]{background:var(--vibeui-button-017-done);cursor:default;opacity:1}
[data-vibeui-block="button-017"] [data-part="spinner"]{
flex:none;width:0.875rem;height:0.875rem;box-sizing:border-box;
border:2px solid oklch(1 0 0 / 45%);border-top-color:currentColor;border-radius:9999px;
animation:vibeui-button-017-spin .7s linear infinite;
}
@keyframes vibeui-button-017-spin{to{transform:rotate(360deg)}}
[data-vibeui-block="button-017"] [data-part="tick"]{
flex:none;width:0.3125rem;height:0.5625rem;margin-bottom:0.125rem;
border-right:2px solid currentColor;border-bottom:2px solid currentColor;
transform:rotate(45deg);
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="button-017"] *{animation:none!important;transition:none!important}
[data-vibeui-block="button-017"] [data-part="spinner"]{border-top-color:oklch(1 0 0 / 45%);border-left-color:currentColor}
}
`
const wait = () => new Promise<void>((resolve) => setTimeout(resolve, 1400))
/**
* Кнопка отправки, которую нельзя нажать дважды.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Button017({
label = "Отправить заявку",
pendingLabel = "Отправляем…",
doneLabel = "Отправлено",
action = wait,
accent,
type = "button",
className,
style,
...props
}: Button017Props) {
const [state, setState] = useState<"idle" | "pending" | "done">("idle")
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current)
},
[],
)
const palette = {
...(accent ? { "--vibeui-button-017-accent": accent } : null),
...style,
} as CSSProperties
const run = async () => {
if (state !== "idle") return
setState("pending")
await action()
setState("done")
timer.current = setTimeout(() => setState("idle"), 2200)
}
return (
<>
<style href="vibeui-button-017" precedence="medium">
{STYLES}
</style>
<button
{...props}
type={type}
data-vibeui-block="button-017"
data-state={state}
className={className}
style={palette}
disabled={state === "pending"}
aria-busy={state === "pending"}
onClick={run}
>
{state === "pending" ? (
<span data-part="spinner" aria-hidden="true" />
) : null}
{state === "done" ? <span data-part="tick" aria-hidden="true" /> : null}
<span aria-live="polite">
{state === "pending"
? pendingLabel
: state === "done"
? doneLabel
: label}
</span>
</button>
</>
)
}