Buttons

Retry Action Button

A long action with three outcomes: while the request runs the button turns into Cancel, after a failure it offers a retry and counts the attempts. The width never jumps as the label changes.

  • button
  • async
  • retry
  • cancel

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-021?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-021" (Retry Action 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-021.json

Registry item: https://vibeui.ru/r/button-021.json
Installs to: components/vibeui/button-021.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 long action with three outcomes: while the request runs the button turns into Cancel, after a failure it offers a retry and counts the attempts. The width never jumps as the label changes.

A long-running action button with three outcomes. While the request is in flight the label turns into Cancel, and the click really does stop the work through an AbortSignal. Success shows a checkmark and clears itself, a failure moves the button into a red Retry with an attempt counter. The labels sit stacked in one grid cell, so the button's width stays constant.

## 3. How to use it
import { Button021 } from "@/components/vibeui/button-021"

<Button021
  label="Sync project"
  action={(signal) => syncProject(signal)}
/>

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 labels stacked in a single grid cell — the width is taken from the longest one and never jumps between states, which min-width cannot guarantee
- visibility:hidden on the inactive labels: they hold their space but stay out of the screen reader
- the error state as a separate outcome: without it the button would silently slide back to rest after a failure
- the cancellation through AbortController — while working the button does not lock, it aborts the request
- the attempt counter next to the retry label: it explains that this is no longer the first go
- the spinner, bang and check glyphs together with aria-busy: the state must not rest on colour alone

## 6. You may change
- the label, pendingLabel, errorLabel and doneLabel captions
- the work itself through the action prop: it receives an AbortSignal and is expected to honour it
- the accent colour through the accent prop
- outer spacing and width through className
- the pause before leaving the done state — it is a single setTimeout in the code

## 7. Rules
- The component knows nothing about the network: inside there is only a showcase stub that fails on the first attempt. Pass your own action in the app.
- Do not lock the button from outside while it works — that removes the cancellation it exists for.
- If the labels get noticeably longer the button grows to the longest one: that is how the stack works, not a layout bug.
- 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-021.json
https://vibeui.ru/r/button-021.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-button-021-*. Клиентский: состояния idle/pending/error/done внутренние. Работа передаётся пропом action, который получает AbortSignal внутреннего AbortController, поэтому клик во время работы отменяет запрос. Четыре подписи лежат друг на друге в одной ячейке grid, неактивные скрыты visibility:hidden — ширина считается по самой длинной. Дефолтный action — витринная заглушка, которая падает на первой попытке.

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 Button021Props = Omit<
  ComponentPropsWithoutRef<"button">,
  "children" | "onClick"
> & {
  label?: string
  pendingLabel?: string
  errorLabel?: string
  doneLabel?: string
  /** Что делаем по нажатию. Сигнал приходит из внутреннего AbortController. */
  action?: (signal: AbortSignal) => Promise<void>
  accent?: string
}

// Идея компонента: длинное действие, которое может не получиться. Пока запрос
// идёт, кнопка не блокируется, а превращается в «Отмена» и отдаёт AbortSignal;
// после ошибки предлагает повтор и считает попытки. Все четыре подписи лежат
// в одной ячейке grid друг поверх друга, поэтому ширина считается по самой
// длинной из них и не прыгает при смене состояния.
const STYLES = `
:where([data-vibeui-block="button-021"]){
--vibeui-button-021-accent:oklch(0.55 0.17 265);
--vibeui-button-021-fg:oklch(0.99 0.01 265);
--vibeui-button-021-error:oklch(0.55 0.19 25);
--vibeui-button-021-done:oklch(0.53 0.14 152);
--vibeui-button-021-radius:0.625rem;
--vibeui-button-021-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="button-021"]{
appearance:none;border:0;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;gap:0.5rem;
height:2.5rem;padding:0 1.125rem;box-sizing:border-box;
border-radius:var(--vibeui-button-021-radius);
background:var(--vibeui-button-021-accent);color:var(--vibeui-button-021-fg);
font-family:var(--vibeui-button-021-font);font-size:0.875rem;font-weight:650;line-height:1;
transition:background-color .18s ease,filter .18s ease;
}
[data-vibeui-block="button-021"]:hover{filter:brightness(0.96)}
[data-vibeui-block="button-021"]:focus-visible{outline:2px solid var(--vibeui-button-021-accent);outline-offset:2px}
[data-vibeui-block="button-021"][data-state="error"]{background:var(--vibeui-button-021-error)}
[data-vibeui-block="button-021"][data-state="done"]{background:var(--vibeui-button-021-done)}
/* Подписи стоят друг на друге: ширина берётся по самой длинной. */
[data-vibeui-block="button-021"] [data-part="stack"]{display:grid;align-items:center;justify-items:center}
[data-vibeui-block="button-021"] [data-part="stack"] > span{grid-area:1 / 1;white-space:nowrap}
[data-vibeui-block="button-021"] [data-part="stack"] > span[data-active="false"]{visibility:hidden}
[data-vibeui-block="button-021"] [data-part="count"]{opacity:.75;font-variant-numeric:tabular-nums}
[data-vibeui-block="button-021"] [data-part="spinner"]{
flex:none;width:0.875rem;height:0.875rem;box-sizing:border-box;
border:2px solid oklch(1 0 0 / 40%);border-top-color:currentColor;border-radius:9999px;
animation:vibeui-button-021-spin .7s linear infinite;
}
[data-vibeui-block="button-021"] [data-part="bang"]{
flex:none;width:0.875rem;height:0.875rem;border-radius:9999px;
background:oklch(1 0 0 / 25%);position:relative;
}
[data-vibeui-block="button-021"] [data-part="bang"]::after{
content:"";position:absolute;left:50%;top:0.1875rem;width:2px;height:0.5rem;
margin-left:-1px;border-radius:1px;
background:currentColor;box-shadow:0 0.1875rem 0 currentColor;
}
[data-vibeui-block="button-021"] [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);
}
@keyframes vibeui-button-021-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="button-021"] *{animation:none!important;transition:none!important}
[data-vibeui-block="button-021"] [data-part="spinner"]{border-top-color:oklch(1 0 0 / 40%);border-left-color:currentColor}
}
`

type State = "idle" | "pending" | "error" | "done"

/**
 * Кнопка длинного действия с отменой и повтором после ошибки.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Button021({
  label = "Синхронизировать",
  pendingLabel = "Отмена",
  errorLabel = "Повторить",
  doneLabel = "Готово",
  action,
  accent,
  type = "button",
  className,
  style,
  ...props
}: Button021Props) {
  const [state, setState] = useState<State>("idle")
  const [attempt, setAttempt] = useState(0)
  const controller = useRef<AbortController | null>(null)
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const tries = useRef(0)

  useEffect(
    () => () => {
      controller.current?.abort()
      if (timer.current) clearTimeout(timer.current)
    },
    [],
  )

  // Витринная заглушка: первая попытка падает, вторая проходит — иначе
  // состояние ошибки на превью никто не увидит.
  const demo = (signal: AbortSignal) =>
    new Promise<void>((resolve, reject) => {
      tries.current += 1
      const fails = tries.current % 2 === 1
      const id = setTimeout(
        () => (fails ? reject(new Error("demo")) : resolve()),
        1500,
      )
      signal.addEventListener("abort", () => {
        clearTimeout(id)
        reject(new Error("aborted"))
      })
    })

  const press = async () => {
    if (state === "pending") {
      controller.current?.abort()
      return
    }

    const local = new AbortController()
    controller.current = local
    setState("pending")

    try {
      await (action ?? demo)(local.signal)
      if (local.signal.aborted) return
      setAttempt(0)
      setState("done")
      timer.current = setTimeout(() => setState("idle"), 2000)
    } catch {
      if (local.signal.aborted) {
        setState("idle")
        return
      }
      setAttempt((value) => value + 1)
      setState("error")
    }
  }

  const palette = {
    ...(accent ? { "--vibeui-button-021-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-button-021" precedence="medium">
        {STYLES}
      </style>
      <button
        {...props}
        type={type}
        data-vibeui-block="button-021"
        data-state={state}
        className={className}
        style={palette}
        aria-busy={state === "pending"}
        onClick={press}
      >
        {state === "pending" ? (
          <span data-part="spinner" aria-hidden="true" />
        ) : null}
        {state === "error" ? (
          <span data-part="bang" aria-hidden="true" />
        ) : null}
        {state === "done" ? <span data-part="tick" aria-hidden="true" /> : null}
        <span data-part="stack" aria-live="polite">
          <span data-active={String(state === "idle")}>{label}</span>
          <span data-active={String(state === "pending")}>{pendingLabel}</span>
          <span data-active={String(state === "error")}>
            {errorLabel}
            {attempt > 1 ? <span data-part="count"> · {attempt}</span> : null}
          </span>
          <span data-active={String(state === "done")}>{doneLabel}</span>
        </span>
      </button>
    </>
  )
}