Componentes en CSS puro: botones, tarjetas, pestañas y más
En esta lección
Si miras diez webs distintas, encontrarás las mismas piezas una y otra vez: botones, tarjetas, etiquetas pequeñas de “Nuevo”, mensajes de aviso, menús que se despliegan, pestañas, globos de ayuda e interruptores de “activado/desactivado”. A cada una de esas piezas reutilizables se la llama componente. Frameworks como Bootstrap te los dan hechos, pero saber construirlos tú mismo te permite entender qué hacen, adaptarlos a tu diseño y no cargar CSS que no usas. En esta lección vas a crear ocho componentes en CSS puro, cuidando algo que muchos tutoriales olvidan: que funcionen con teclado y con lector de pantalla.
Antes de empezar: tres reglas para cualquier componente
- Usa el elemento HTML correcto. Un botón es un
<button>, un enlace es un<a>, una casilla es un<input type="checkbox">. El HTML adecuado trae gratis el teclado, el foco y lo que anuncia el lector de pantalla. El CSS solo cambia el aspecto. - Diseña todos los estados, no solo el normal:
:hover,:focus-visible,:active,:disabled, marcado, abierto… Los tienes todos en pseudoclases a fondo. - Centraliza los valores en variables. Colores, radios y espacios en variables hacen que cambiar el aspecto de todos los componentes sea editar unas pocas líneas.
Para los nombres de clase usaremos un estilo parecido a BEM: el componente (.boton), y sus variantes con dos guiones (.boton--secundario). Lo tienes explicado en organizar el CSS.
:root {
--primario: #1d4ed8;
--primario-oscuro: #1e3a8a;
--borde: #cbd5e1;
--radio: 8px;
--foco: #f59e0b;
}
1. Botones con estados
<button class="boton" type="button">Reservar</button>
<button class="boton boton--secundario" type="button">Ver horarios</button>
<button class="boton boton--peligro" type="button">Cancelar reserva</button>
<a class="boton" href="/tarifas/">Ver tarifas</a>
.boton {
--fondo: var(--primario);
--texto: white;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em; /* espacio si lleva un icono */
min-height: 44px; /* tamaño táctil cómodo */
padding: 0 1.2em;
border: 2px solid var(--fondo);
border-radius: var(--radio);
background: var(--fondo);
color: var(--texto);
font: inherit;
font-weight: 600;
text-decoration: none; /* por si es un <a> */
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
.boton:hover { --fondo: var(--primario-oscuro); }
.boton:active { translate: 0 1px; }
.boton:focus-visible { outline: 3px solid var(--foco); outline-offset: 2px; }
.boton:disabled,
.boton[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Variantes: solo cambian las variables */
.boton--secundario { --texto: var(--primario); background: transparent; }
.boton--secundario:hover { --texto: white; background: var(--primario-oscuro); }
.boton--peligro { --fondo: #b91c1c; }
.boton--peligro:hover { --fondo: #7f1d1d; }
Fíjate en el truco de las variables locales --fondo y --texto: el :hover y las variantes no repiten background, border-color y color, solo cambian una variable. Añadir una variante nueva cuesta una línea.
Detalles que marcan la diferencia:
:focus-visiblemuestra el anillo de foco cuando se navega con teclado, pero no al hacer clic con el ratón. Nunca quites el foco conoutline: nonesin poner otro.:disabledsolo existe en<button>e<input>. Un<a>no se puede desactivar; si necesitas que lo parezca, usaaria-disabled="true", que el lector de pantalla anuncia como “no disponible”.- ¿
<button>o<a>? Si lleva a otra página, es un enlace aunque parezca un botón. Si hace algo en la página (abrir, enviar, borrar), es un botón.
2. Tarjetas
Una tarjeta agrupa una imagen, un título, un texto breve y un enlace. Muchas veces se quiere que toda la tarjeta sea clicable. Envolverla entera en un <a> funciona, pero el lector de pantalla lee todo su contenido como el nombre del enlace. La técnica del enlace estirado es mejor: solo el título es un enlace, y su ::after cubre toda la tarjeta.
<article class="tarjeta">
<img src="ruta-cares.jpg" alt="" width="400" height="250">
<div class="tarjeta-cuerpo">
<h3><a href="/rutas/cares/">Ruta del Cares</a></h3>
<p>12 km entre Poncebos y Caín por un desfiladero excavado en la roca.</p>
<span class="insignia">Dificultad media</span>
</div>
</article>
.tarjeta {
position: relative; /* referencia para el enlace estirado */
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--borde);
border-radius: 12px;
background: white;
transition: translate 0.2s, box-shadow 0.2s;
}
.tarjeta img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.tarjeta-cuerpo {
display: grid;
gap: 0.5rem;
padding: 1rem;
}
.tarjeta h3 { margin: 0; }
.tarjeta h3 a { color: inherit; text-decoration: none; }
.tarjeta h3 a::after {
content: "";
position: absolute;
inset: 0; /* cubre toda la tarjeta */
}
.tarjeta:hover {
translate: 0 -4px;
box-shadow: 0 10px 24px rgb(0 0 0 / 0.12);
}
.tarjeta:has(a:focus-visible) {
outline: 3px solid var(--foco);
outline-offset: 2px;
}
La imagen lleva alt="" porque es decorativa: el título ya dice de qué trata. :has(a:focus-visible) dibuja el foco en la tarjeta entera cuando el enlace recibe el foco con teclado. Para colocar varias tarjetas en rejilla, mira maquetaciones típicas.
Cuidado: con el enlace estirado, cualquier otro enlace o botón dentro de la tarjeta queda tapado por el
::after. Si necesitas uno, daleposition: relativeyz-index: 1para que quede por encima.
3. Insignias
Una insignia (badge) es una etiqueta pequeña: “Nuevo”, “Agotado”, “3” mensajes sin leer.
.insignia {
display: inline-block;
padding: 0.15em 0.6em;
border-radius: 999px; /* forma de píldora */
background: #fde68a;
color: #713f12;
font-size: 0.75rem;
font-weight: 700;
line-height: 1.4;
vertical-align: middle;
}
.insignia--agotado { background: #fee2e2; color: #7f1d1d; }
Un border-radius muy grande, como 999px, siempre da extremos completamente redondos, sea cual sea el tamaño.
Para un contador sobre un icono, posiciónala en la esquina:
<a class="icono-carrito" href="/carrito/">
<svg aria-hidden="true" width="24" height="24"><!-- icono --></svg>
<span class="visually-hidden">Carrito:</span>
<span class="insignia insignia--contador">3</span>
<span class="visually-hidden">productos</span>
</a>
.icono-carrito { position: relative; display: inline-block; }
.insignia--contador {
position: absolute;
top: -6px;
right: -10px;
min-width: 1.5em;
text-align: center;
background: #b91c1c;
color: white;
}
Un “3” rojo no dice nada a quien no ve la pantalla. Los textos con .visually-hidden (explicada en CSS accesible) hacen que se lea “Carrito: 3 productos”.
4. Alertas
Una alerta es un mensaje que destaca: éxito, información, advertencia o error.
<div class="alerta alerta--exito" role="status">
Te has apuntado a la clase del sábado.
</div>
<div class="alerta alerta--error" role="alert">
No quedan plazas para ese horario.
</div>
.alerta {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid;
border-inline-start-width: 6px;
border-radius: var(--radio);
}
.alerta::before {
font-weight: 700;
}
.alerta--exito {
background: #dcfce7;
border-color: #16a34a;
color: #14532d;
}
.alerta--exito::before { content: "✓" / ""; }
.alerta--error {
background: #fee2e2;
border-color: #b91c1c;
color: #7f1d1d;
}
.alerta--error::before { content: "!" / ""; }
Varias cosas a destacar:
border: 1px solidsin color usa elcolordel texto… hasta que la variante poneborder-color. Yborder-inline-start-widthengorda solo el borde del lado donde empieza el texto (la izquierda en español). Lo tienes en propiedades lógicas.- El icono no es solo color: hay un símbolo distinto y, sobre todo, un texto que explica qué pasa.
content: "✓" / ""usa el texto alternativo decontent: lo que va después de la barra es lo que lee el lector de pantalla. Una cadena vacía significa “decorativo, no lo leas”.role="status"yrole="alert"hacen que el lector de pantalla anuncie el mensaje cuando aparece en la página.alertinterrumpe lo que esté leyendo, así que resérvalo para errores. Más en ARIA a fondo.
5. Menú desplegable
La forma más sencilla de hacer un desplegable sin JavaScript es el elemento <details>: ya se abre y cierra con clic, con Enter y con la barra espaciadora.
<details class="desplegable">
<summary>Mi cuenta</summary>
<ul class="desplegable-menu">
<li><a href="/reservas/">Mis reservas</a></li>
<li><a href="/perfil/">Perfil</a></li>
<li><a href="/salir/">Cerrar sesión</a></li>
</ul>
</details>
.desplegable {
position: relative;
display: inline-block;
}
.desplegable summary {
list-style: none; /* quita el triángulo por defecto */
padding: 0.5rem 1rem;
border: 1px solid var(--borde);
border-radius: var(--radio);
cursor: pointer;
}
.desplegable summary::-webkit-details-marker { display: none; } /* Safari */
.desplegable summary::after {
content: "▾" / "";
margin-left: 0.5em;
display: inline-block;
transition: rotate 0.2s;
}
.desplegable[open] summary::after { rotate: 180deg; }
.desplegable-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 10;
min-width: 180px;
margin: 0;
padding: 0.25rem;
list-style: none;
border: 1px solid var(--borde);
border-radius: var(--radio);
background: white;
box-shadow: 0 8px 20px rgb(0 0 0 / 0.12);
}
.desplegable-menu a {
display: block;
padding: 0.5rem 0.75rem;
border-radius: 4px;
color: inherit;
text-decoration: none;
}
.desplegable-menu a:hover,
.desplegable-menu a:focus-visible { background: #eff6ff; }
El menú se posiciona con position: absolute respecto al <details>, que tiene position: relative (lo tienes en posicionamiento). Si queda por debajo de otra cosa, revisa z-index y capas.
Una limitación: <details> no se cierra al hacer clic fuera ni al pulsar Escape. Si lo necesitas, el atributo HTML popover sí lo hace sin JavaScript; lo tienes en details y dialog.
6. Pestañas con radios y :has()
Unas pestañas muestran un solo panel cada vez. El “estado” (qué pestaña está activa) lo puede guardar un grupo de botones de opción: solo uno puede estar marcado. Con :has(), el contenedor sabe cuál es y muestra el panel correspondiente.
<div class="pestanas">
<div class="pestanas-lista">
<label><input class="visually-hidden" type="radio" name="tarifa" value="mes" checked> Mensual</label>
<label><input class="visually-hidden" type="radio" name="tarifa" value="bono"> Bono 10</label>
<label><input class="visually-hidden" type="radio" name="tarifa" value="anual"> Anual</label>
</div>
<section class="panel" data-panel="mes">Cuota mensual: 42 €.</section>
<section class="panel" data-panel="bono">Bono de 10 entradas: 75 €.</section>
<section class="panel" data-panel="anual">Cuota anual: 420 €.</section>
</div>
.pestanas-lista {
display: flex;
border-bottom: 2px solid var(--borde);
}
.pestanas-lista label {
padding: 0.5rem 1rem;
border-bottom: 3px solid transparent;
margin-bottom: -2px; /* se monta sobre la línea gris */
position: relative; /* referencia para el radio oculto */
cursor: pointer;
}
.pestanas-lista label:has(:checked) {
border-color: var(--primario);
color: var(--primario);
font-weight: 700;
}
.pestanas-lista label:has(:focus-visible) {
outline: 3px solid var(--foco);
}
.panel { display: none; padding: 1rem 0; }
.pestanas:has([value="mes"]:checked) [data-panel="mes"],
.pestanas:has([value="bono"]:checked) [data-panel="bono"],
.pestanas:has([value="anual"]:checked) [data-panel="anual"] {
display: block;
}
Lee la última regla así: “dentro de unas .pestanas que tienen marcado el radio mes, muestra el panel mes”.
¿Por qué esconder los radios con .visually-hidden y no con display: none? Porque con display: none dejan de recibir el foco y el teclado ya no puede usar las pestañas. Así, el usuario llega con Tab y cambia de pestaña con las flechas, que es como funcionan los grupos de radios.
Nota: el lector de pantalla anunciará “botón de opción”, no “pestaña”. Es una solución accesible y válida para casos sencillos, como elegir tarifa. Unas pestañas completas según el patrón de ARIA (
role="tablist",role="tab",aria-selected) necesitan JavaScript para mover el foco y actualizar los atributos.
7. Tooltip
Un tooltip es un globo pequeño con información extra que aparece al pasar el ratón o al enfocar un elemento. Para que sea accesible, el texto debe estar en el HTML (no en un atributo title, que no aparece con teclado ni en el móvil) y unido al control con aria-describedby:
<span class="con-tooltip">
<button class="boton" type="button" aria-describedby="tip-guardar">Guardar</button>
<span class="tooltip" role="tooltip" id="tip-guardar">Atajo: Ctrl + S</span>
</span>
.con-tooltip {
position: relative;
display: inline-block;
}
.tooltip {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
translate: -50% 0;
padding: 0.35rem 0.6rem;
border-radius: 6px;
background: #1e293b;
color: white;
font-size: 0.85rem;
white-space: nowrap;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s, visibility 0.15s;
}
/* La flecha: un cuadrado girado bajo el globo */
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
width: 8px;
height: 8px;
background: inherit;
translate: -50% -50%;
rotate: 45deg;
}
.con-tooltip:hover .tooltip,
.con-tooltip:focus-within .tooltip {
opacity: 1;
visibility: visible;
}
visibility: hiddenademás deopacity: 0evita que el globo invisible siga “ahí” para el ratón. Y a diferencia dedisplay: none,visibilityse puede animar con transiciones.:focus-withinmuestra el tooltip también cuando el botón recibe el foco con el teclado.aria-describedbyhace que el lector de pantalla lea “Guardar, botón, Atajo: Ctrl + S”.
Pon en el tooltip solo información extra. Si algo es imprescindible para usar la página, escríbelo visible.
8. Interruptor (toggle)
Un interruptor es una casilla con aspecto de palanca de “activado/desactivado”. Por dentro, sigue siendo un <input type="checkbox">, ahora con role="switch" para que el lector de pantalla diga “interruptor, activado”.
<label class="interruptor">
<input type="checkbox" role="switch" name="avisos">
Avisarme cuando haya plazas libres
</label>
.interruptor {
display: inline-flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
}
.interruptor input {
appearance: none;
position: relative;
flex-shrink: 0;
width: 44px;
height: 24px;
margin: 0;
border-radius: 999px;
background: #94a3b8;
cursor: pointer;
transition: background-color 0.2s;
}
/* La bola */
.interruptor input::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
transition: translate 0.2s;
}
.interruptor input:checked { background: #16a34a; }
.interruptor input:checked::before { translate: 20px 0; }
.interruptor input:focus-visible {
outline: 3px solid var(--foco);
outline-offset: 2px;
}
.interruptor input:disabled { opacity: 0.5; cursor: not-allowed; }
appearance: none quita el dibujo nativo de la casilla y nos deja una caja vacía a la que se le puede poner ::before, como viste en formularios. La bola se desplaza 20 px: el ancho (44) menos la bola (18) menos los dos márgenes de 3 px. Como es un checkbox de verdad, funciona con la barra espaciadora, se envía con el formulario y el <label> lo activa al hacer clic en el texto.
Reducir el movimiento
Varios componentes tienen pequeñas transiciones. Respeta a quien ha pedido menos movimiento:
@media (prefers-reduced-motion: reduce) {
.boton,
.tarjeta,
.tooltip,
.interruptor input,
.interruptor input::before {
transition: none;
}
.tarjeta:hover { translate: none; }
}
Errores frecuentes
- Hacer botones con
<div>. No reciben el foco, no se activan con Enter y el lector no los anuncia. Usa<button>. outline: nonesin alternativa. Quien navega con teclado deja de saber dónde está. Usa:focus-visiblecon un anillo bien visible.- Esconder los radios o casillas con
display: none. Dejan de funcionar con teclado. Usa.visually-hiddenoappearance: none. - Envolver una tarjeta entera en
<a>. El nombre del enlace se vuelve larguísimo. Usa el enlace estirado con::after. - Información solo con color. Una insignia roja o una alerta verde necesitan también texto.
- Tooltips con
titleo solo con:hover. No funcionan con teclado ni en el móvil. Usa:focus-withinyaria-describedby. - Repetir colores en cada variante. Si cada variante reescribe
background,border-colorycolor, cuesta mantenerlo. Cambia solo variables locales. - Olvidar el estado desactivado. Un botón
disabledque sigue cambiando de color al pasar el ratón confunde.
Resumen
| Componente | HTML base | Claves de CSS |
|---|---|---|
| Botón | <button> o <a> | Variables locales, :hover, :focus-visible, :disabled |
| Tarjeta | <article> con enlace en el título | position: relative y a::after { inset: 0 } |
| Insignia | <span> | border-radius: 999px, font-size pequeño |
| Alerta | <div role="status"> o role="alert" | Borde lateral, icono decorativo con content: "..." / "" |
| Desplegable | <details> y <summary> | Menú con position: absolute, [open] |
| Pestañas | Radios dentro de <label> | .visually-hidden, :has(:checked) |
| Tooltip | aria-describedby + role="tooltip" | visibility, opacity, :focus-within |
| Interruptor | <input type="checkbox" role="switch"> | appearance: none, ::before, :checked |
Ya tienes las piezas. En la siguiente lección verás qué más ofrece el CSS actual: CSS moderno.
Pruébalo tú
Cambia el código y pulsa Ejecutar (o Ctrl + Enter).
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.