Los estados de un botón en CSS son los estilos que muestra cuando pasas el puntero por encima (:hover), cuando recibe el foco con el teclado (:focus-visible), cuando se pulsa (:active), cuando está deshabilitado (:disabled o aria-disabled) o cuando está cargando. Aquí tienes un mismo botón en todos sus estados, con CSS puro y Tailwind para copiar, además de botones outline, ghost, de cierre y con icono que siguen siendo accesibles.
Un botón tiene seis estados que vale la pena diseñar: predeterminado, hover, focus-visible, active, deshabilitado y cargando. La mayoría de los fragmentos que encuentras en internet muestran uno o dos, en botones distintos y con código distinto. Esta guía usa un solo botón de principio a fin, muestra todos los estados uno al lado del otro y te da CSS puro y Tailwind para copiar en cada caso. Después repasa las variantes más buscadas (outline, ghost, cierre, icono) y la diferencia entre disabled y aria-disabled, que va más allá del estilo.
Cada recuadro de abajo muestra un estado para que puedas compararlos de un vistazo. El botón interactivo bajo la cuadrícula te permite forzar un estado o probarlo con tu propio ratón y teclado.
| Estado | Selector CSS | Variante de Tailwind | Qué lo activa |
|---|---|---|---|
| Predeterminado | .btn | (sin prefijo) | Nada |
| Hover | .btn:hover | hover: | Un puntero sobre el botón. Las pantallas táctiles no tienen un hover real. |
| Focus-visible | .btn:focus-visible | focus-visible: | El foco que el navegador decide mostrar, normalmente el foco del teclado (Tab) |
| Focus | .btn:focus | focus: | Cualquier foco, incluido un clic del ratón en la mayoría de los navegadores |
| Active | .btn:active | active: | Mientras se mantiene pulsado el botón del ratón, el dedo o la tecla Space |
| Deshabilitado | .btn:disabled | disabled: | El atributo disabled |
| Deshabilitado (ARIA) | .btn[aria-disabled="true"] | aria-disabled: | El atributo aria-disabled="true" |
| Cargando | .btn[aria-busy="true"] | aria-busy: | Tu script, mientras el trabajo está en curso |
En CSS puro el orden importa cuando los selectores tienen la misma especificidad: pon primero :hover, luego :focus-visible y después :active, para que pulsar tenga prioridad sobre pasar el puntero. Tailwind ordena sus variantes por ti.
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.5rem; /* 40px, above the 24px WCAG minimum */
padding: 0.625rem 1rem;
border: 1px solid transparent; /* keeps size equal to the outline variant */
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
font: 600 0.875rem/1.25rem system-ui, sans-serif;
cursor: pointer;
transition: background-color 150ms, transform 150ms;
}
/* hover only when the button can actually be used */
.btn:hover:not(:disabled, [aria-disabled="true"]) {
background: #1d4ed8;
}
/* keyboard focus; mouse clicks do not show the ring */
.btn:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.btn:active:not(:disabled, [aria-disabled="true"]) {
background: #1e40af;
transform: translateY(1px);
}
.btn:disabled,
.btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.btn[aria-busy="true"] {
cursor: progress;
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn:active { transform: none; }
}
Tres detalles hacen casi todo el trabajo. La protección :not(:disabled, [aria-disabled="true"]) evita que un botón deshabilitado reaccione al hover o a la pulsación, algo que de lo contrario lo haría parecer clicable. El borde transparente mantiene un botón sólido del mismo tamaño que uno outline, así que cambiar de variante no desplaza la maquetación. Y usar :focus-visible en lugar de :focus hace que quienes usan el ratón no vean un anillo tras hacer clic, mientras que quienes usan el teclado siempre lo ven.
Úsalo así: <button type="button" class="btn">Save changes</button>. Indica siempre el type: un <button> dentro de un formulario es type="submit" por defecto y enviará el formulario al hacer clic.
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-transparent
bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition
hover:bg-blue-700 active:translate-y-px active:bg-blue-800
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-blue-600 disabled:active:translate-y-0
aria-disabled:cursor-not-allowed aria-disabled:opacity-50
motion-reduce:transition-none">
Save changes
</button>
Estas clases funcionan en Tailwind 3.4 y 4. Dos notas sobre la v4: hover: solo se aplica en dispositivos que admiten hover (@media (hover: hover)), y los estilos base de la v4 dan a los botones cursor: default, así que añade cursor-pointer si quieres el cursor de mano. Consulta la chuleta de Tailwind v4 para ver el resto de valores predeterminados que cambiaron, como el ring de 1px.
El criterio WCAG 2.4.7 Foco visible (nivel AA) exige que el foco del teclado sea visible. Quitar el contorno del navegador con outline: none sin poner nada a cambio no lo cumple. Pautas que funcionan:
:focus-visible para el anillo. Lo admiten todos los navegadores principales actuales, así que el viejo truco :focus:not(:focus-visible) ya no hace falta.outline en lugar de box-shadow para el anillo. Los contornos siguen visibles en el modo de colores forzados de Windows (alto contraste), donde las sombras se eliminan.outline-offset de 2px deja un espacio entre el botón y el anillo, así que un anillo azul se sigue viendo junto a un botón azul.Un botón en carga tiene que hacer cuatro cosas: mostrar que hay trabajo en curso, bloquear un segundo envío, mantener el foco del teclado donde estaba y avisar a los usuarios de lectores de pantalla cuando el trabajo termine. Poner disabled durante la petición rompe el tercer punto: el botón pierde el foco y, en algunos navegadores, quienes usan el teclado vuelven al principio de la página. Usa aria-disabled y añade la protección en tu script.
<button type="submit" class="btn" aria-disabled="true" aria-busy="true">
<svg class="spinner" viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3"/>
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
</svg>
Saving
</button>
<!-- one live region on the page, outside the button -->
<p class="visually-hidden" role="status">Saving your changes</p>
.spinner { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2.4s; } /* slow it, keep a sign of progress */
}
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (button.getAttribute("aria-disabled") === "true") return; // ignore double clicks
button.setAttribute("aria-disabled", "true");
button.setAttribute("aria-busy", "true");
status.textContent = "Saving your changes";
try {
await save(new FormData(form));
status.textContent = "Saved";
} catch {
status.textContent = "Could not save. Try again.";
} finally {
button.removeAttribute("aria-disabled");
button.removeAttribute("aria-busy");
}
});
Mantén la etiqueta como un verbo ("Guardando", "Enviando") en lugar de sustituirla solo por un spinner, mantén fijo el ancho del botón (un min-width evita que se encoja cuando cambia el texto) y anuncia el resultado mediante la región de estado, no solo cambiando el texto del botón. aria-busy indica que el elemento se está actualizando; cada lector de pantalla hace algo distinto con él, por eso el mensaje de estado es el que lleva el anuncio real.
Los dos hacen que las tecnologías de apoyo anuncien el botón como no disponible (a menudo "atenuado" o "no disponible"). La diferencia está en el comportamiento. Como explica MDN, el atributo HTML disabled anula toda la funcionalidad, mientras que aria-disabled="true" solo expone el estado deshabilitado, así que tienes que anular el comportamiento tú mismo.
disabled | aria-disabled="true" | |
|---|---|---|
| Accesible con Tab | No, sale del orden de tabulación | Sí, sigue pudiendo recibir el foco |
| Activación con clic y teclado | Bloqueada por el navegador | Se siguen disparando; tu script debe ignorarlas |
| Envía un formulario | No. Su nombre y su valor no se envían y, si es el botón predeterminado del formulario, pulsar Enter en un campo no lo envía | Sí, salvo que llames a preventDefault() |
| Puede mostrar un tooltip o una explicación al recibir el foco | No, no puede recibir el foco | Sí, con aria-describedby |
| Gancho para estilos | :disabled / disabled: | [aria-disabled="true"] / aria-disabled: |
Funciona en enlaces (<a>) | No, disabled no es válido en enlaces | Sí, junto con quitar el href |
Regla práctica: usa disabled cuando el botón de verdad no se puede usar y nadie necesita saber por qué, por ejemplo un control de formulario dentro de una sección desactivada. Usa aria-disabled cuando el usuario necesita encontrar el botón y saber qué falta ("Escribe tu email para continuar"), y durante la carga. A menudo la mejor respuesta es ninguno de los dos: deja el botón habilitado y muestra un mensaje de error claro cuando se pulse con datos incompletos.
button.addEventListener("click", (event) => {
if (button.getAttribute("aria-disabled") === "true") {
event.preventDefault(); // also stops a submit button from submitting
return;
}
doTheThing();
});
Sobre el contraste: WCAG 1.4.3 exime del contraste mínimo al texto de los componentes inactivos (deshabilitados), por eso se permite un botón atenuado al 50% de opacidad. Aun así conviene que la etiqueta sea legible, porque la gente necesita ver lo que todavía no puede hacer.
Un botón outline tiene relleno transparente y borde visible; un botón ghost no tiene ninguno de los dos hasta que pasas el puntero. Úsalos para acciones menos importantes junto a un botón principal sólido: outline para una segunda opción clara ("Vista previa"), ghost para acciones discretas en barras de herramientas y menús ("Cancelar", "Editar").
Son botones reales: pasa el puntero por encima y pulsa Tab para ver cada estado. El CSS de las variantes, sobre la base .btn de arriba:
/* Outline: transparent fill, visible border */
.btn-outline {
background: transparent;
color: #1d4ed8;
border-color: #2563eb;
}
.btn-outline:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-outline:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }
/* Ghost: no fill, no visible border until hover */
.btn-ghost {
background: transparent;
color: #1d4ed8;
border-color: transparent;
}
.btn-ghost:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-ghost:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }
/* Destructive */
.btn-danger { background: #dc2626; }
.btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: #b91c1c; }
.btn-danger:focus-visible { outline-color: #dc2626; }
<!-- Outline -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-blue-600 bg-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
Outline
</button>
<!-- Ghost -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
Ghost
</button>
<!-- Destructive -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent bg-red-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-red-700 active:bg-red-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:opacity-50">
Delete project
</button>
WCAG 1.4.11 Contraste no textual pide 3:1 frente a los colores adyacentes para las partes de un control necesarias para identificarlo. El borde blue-600 del botón outline sobre blanco lo cumple. Un botón ghost no tiene límite visible en reposo, así que depende de su etiqueta y su posición para parecer clicable: ponlo donde la gente espera acciones, como barras de herramientas, diálogos y pies de tarjeta, y usa un color de texto lo bastante oscuro para el contraste de texto normal (4.5:1).
Un icono X no tiene texto, así que necesita un nombre accesible. Pon aria-label="Close" en el botón y aria-hidden="true" en el SVG para que los lectores de pantalla anuncien "Cerrar, botón" en lugar de nada o un nombre de archivo. Si una página tiene varios botones de cierre, usa nombres específicos: "Cerrar aviso de cookies", "Cerrar diálogo".
Tus cambios se guardaron.
<button type="button" class="btn-close" aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
.btn-close {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem; /* 40px target; WCAG 2.2 AA minimum is 24 by 24 */
padding: 0;
border: 0;
border-radius: 9999px;
background: transparent;
color: #334155;
cursor: pointer;
}
.btn-close:hover { background: rgb(15 23 42 / 0.08); }
.btn-close:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
<button type="button" aria-label="Close"
class="inline-grid size-10 place-items-center rounded-full text-slate-700 hover:bg-slate-900/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg viewBox="0 0 24 24" class="size-5" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
Dimensiona el área táctil, no solo el icono. WCAG 2.5.8 Tamaño del objetivo (mínimo), nivel AA en WCAG 2.2, pide objetivos de al menos 24 por 24 píxeles CSS (con excepciones, como dejar suficiente espacio alrededor de un objetivo más pequeño). El botón de 40px de arriba lo cumple con margen mientras la X dibujada se queda en 20px. Evita usar la letra "x" o el carácter "×" como único contenido sin etiqueta: los lectores de pantalla lo leen como "x" o "por".
<!-- Icon + text: the text is the name, the icon is decoration -->
<button type="button" class="btn">
<svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24">...</svg>
Download PDF
</button>
<!-- Icon only, option 1: aria-label -->
<button type="button" class="btn btn-icon" aria-label="Download PDF">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
</button>
<!-- Icon only, option 2: visually hidden text (gets translated by browser translation tools) -->
<button type="button" class="btn btn-icon">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
<span class="visually-hidden">Download PDF</span>
</button>
.btn svg { flex: none; } /* keep icons from squashing */
.btn-icon {
width: 2.5rem;
padding: 0; /* square: same width and height */
}
<button type="button" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-4 shrink-0" aria-hidden="true" ...></svg>
Download PDF
</button>
<button type="button" aria-label="Download PDF" class="inline-grid size-10 place-items-center rounded-lg bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-5" aria-hidden="true" ...></svg>
</button>
aria-hidden="true") y deja que el texto sea el nombre. Usa gap en lugar de un margen en el icono para que el espaciado siga funcionando si quitas el icono o lo mueves a la derecha.aria-label o con texto visualmente oculto. No dependas de title: los navegadores lo usan como nombre de respaldo, pero su tooltip solo aparece al pasar el ratón, no con el foco del teclado ni al tocar.focusable="false" a los SVG en línea si todavía das soporte a versiones antiguas de Internet Explorer, que convertían los SVG en paradas de tabulación. Los navegadores modernos no lo necesitan, pero no hace daño.<button> (para acciones) o un <a href> (para navegar), no un <div> clicable.type="button", salvo que deba enviar un formulario.prefers-reduced-motion.Para diseñar un botón de forma visual y copiar su CSS, usa el generador de botones CSS. Si buscas conjuntos ya hechos, explora los componentes de botón y los botones animados. Dos piezas que combinan bien con esta guía: el conjunto de botones HTML y CSS con estilos principal, secundario, outline, ghost, icono, carga y destructivo, y la colección de estilos de botón CSS con HTML y CSS para copiar. Para saber dónde colocar los botones en una página y qué deben decir, lee los ejemplos de llamadas a la acción.
Apunta a la pseudoclase :disabled, por ejemplo .btn:disabled { opacity: 0.5; cursor: not-allowed; }. Si también usas aria-disabled, añade [aria-disabled="true"] a la misma regla. Protege tus estilos hover y active con :not(:disabled) para que un botón deshabilitado no cambie de color al pasar el puntero o al pulsarlo.
disabled saca el botón del orden de tabulación, bloquea los clics e impide que envíe un formulario. aria-disabled="true" solo indica a las tecnologías de apoyo que el botón no está disponible: sigue pudiendo recibir el foco y los clics, así que tu script debe ignorar la activación. Usa aria-disabled cuando los usuarios necesiten enfocar el botón para saber por qué no está disponible, o mientras se carga una petición.
Usa :focus-visible para el anillo visible. El navegador lo muestra con el foco del teclado pero normalmente no tras un clic del ratón, así que quienes usan el teclado siempre ven dónde están sin que aparezca un anillo en cada clic. Nunca quites el contorno sin añadir un sustituto visible, ya que WCAG 2.4.7 exige un indicador de foco visible.
Usa un elemento button real con type="button" y aria-label="Close" (o un nombre más específico, como "Cerrar diálogo"), y pon aria-hidden="true" en el icono X. Haz que el área clicable mida al menos 24 por 24 píxeles CSS, el mínimo de WCAG 2.2 AA; de 40 a 44 píxeles es más fácil de acertar.
Un botón outline tiene fondo transparente y borde visible, así que se reconoce como botón en reposo. Un botón ghost no tiene fondo ni borde visible hasta que pasas el puntero, así que depende de su texto y su posición. Usa outline para una acción secundaria clara y ghost para acciones discretas en barras de herramientas o menús.
Añade un spinner con aria-hidden="true", cambia la etiqueta a un verbo como Guardando, pon aria-disabled="true" para que se ignore un segundo clic mientras el foco sigue en el botón, y anuncia el resultado mediante una región role="status". Quita los atributos cuando la petición termine o falle.
Guardando tus cambios
Guardado
Forzar un estado
en vivo (pruébalo)
Guardando
Guardar cambios
Pasa el puntero, llega con Tab, púlsalo o haz clic para ver la carga.
Mostrando el estado {state}.
Se disparó el evento de clic y el script lo ignoró. Escribe tu email para continuar.
Haz clic en los dos y luego recórrelos con Tab: solo se puede llegar a uno.
Continuar
Eventos de clic recibidos: {n}
Empieza gratis · Sin tarjeta de crédito