En este tutorial construimos un componente de “tarjetas expandibles” con HTML, CSS y JavaScript puro. Es perfecto para destacar categorías, fotos o proyectos en un portafolio. Incluye accesibilidad por teclado y trucos de rendimiento.
¿Qué son las Expanding Cards?
Son un conjunto de paneles en fila (o columna en móvil) que se expanden suavemente cuando el usuario interactúa (clic o teclado). La expansión atrae la atención a un elemento sin recargar la interfaz. Ideal para:
-
Portafolios (UX/UI, foto, 3D, arquitectura)
-
Secciones de “categorías destacadas” en blogs o tiendas
-
Landing pages minimalistas con hero gallery
Vista previa del resultado
Cada panel muestra una imagen a pantalla ancha, un título y un gradiente para mejorar la legibilidad. Al activar una tarjeta, su ancho aumenta y el resto se contrae.
Sugerencia: usa 4–6 paneles para un balance visual óptimo.
Estructura del proyecto
Crea una carpeta con tres archivos:
-
index.html
-
style.css
-
script.js
1) HTML básico
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Expanding Cards</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="container" role="list" aria-label="Galería de tarjetas">
<button class="panel active" aria-pressed="true" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop');">
<h3>Playa</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1491553895911-0055eca6402d?q=80&w=1600&auto=format&fit=crop');">
<h3>Montaña</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1?q=80&w=1600&auto=format&fit=crop');">
<h3>Ciudad</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1600&auto=format&fit=crop');">
<h3>Bosque</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1493246318656-5bfd4cfb29ab?q=80&w=1600&auto=format&fit=crop');">
<h3>Lago</h3>
</button>
</main>
<script src="script.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Expanding Cards</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="container" role="list" aria-label="Galería de tarjetas">
<button class="panel active" aria-pressed="true" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop');">
<h3>Playa</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1491553895911-0055eca6402d?q=80&w=1600&auto=format&fit=crop');">
<h3>Montaña</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1?q=80&w=1600&auto=format&fit=crop');">
<h3>Ciudad</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1600&auto=format&fit=crop');">
<h3>Bosque</h3>
</button>
<button class="panel" aria-pressed="false" role="listitem"
style="--bg:url('https://images.unsplash.com/photo-1493246318656-5bfd4cfb29ab?q=80&w=1600&auto=format&fit=crop');">
<h3>Lago</h3>
</button>
</main>
<script src="script.js"></script>
</body>
</html>
2) CSS (estilo y transición)
* { box-sizing: border-box; }
:root { --radius: 24px; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
background: #111;
color: #fff;
min-height: 100svh;
display: grid;
place-items: center;
}
.container {
display: flex;
gap: 16px;
width: min(1200px, 92vw);
height: clamp(260px, 50vw, 520px);
padding: 8px;
}
.panel {
flex: 1;
border: 0;
border-radius: var(--radius);
position: relative;
cursor: pointer;
outline-offset: 4px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 16px;
text-align: left;
transition: flex 0.6s ease;
background: #333 center/cover no-repeat;
background-image: var(--bg);
}
.panel h3 {
margin: 0;
font-size: clamp(1rem, 2.5vw, 1.5rem);
text-shadow: 0 2px 10px rgba(0,0,0,.6);
}
/* activa: ocupa más espacio */
.panel.active { flex: 5; }
/* gradiente para mejorar contraste del texto */
.panel::after{
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(to top, rgba(0,0,0,.55), rgba(0,0,0,0) 60%);
pointer-events: none;
}
/* móvil: apila en columna */
@media (max-width: 540px) {
.container { flex-direction: column; height: auto; }
.panel { min-height: 38svh; }
}
* { box-sizing: border-box; }
:root { --radius: 24px; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
background: #111;
color: #fff;
min-height: 100svh;
display: grid;
place-items: center;
}
.container {
display: flex;
gap: 16px;
width: min(1200px, 92vw);
height: clamp(260px, 50vw, 520px);
padding: 8px;
}
.panel {
flex: 1;
border: 0;
border-radius: var(--radius);
position: relative;
cursor: pointer;
outline-offset: 4px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 16px;
text-align: left;
transition: flex 0.6s ease;
background: #333 center/cover no-repeat;
background-image: var(--bg);
}
.panel h3 {
margin: 0;
font-size: clamp(1rem, 2.5vw, 1.5rem);
text-shadow: 0 2px 10px rgba(0,0,0,.6);
}
/* activa: ocupa más espacio */
.panel.active { flex: 5; }
/* gradiente para mejorar contraste del texto */
.panel::after{
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(to top, rgba(0,0,0,.55), rgba(0,0,0,0) 60%);
pointer-events: none;
}
/* móvil: apila en columna */
@media (max-width: 540px) {
.container { flex-direction: column; height: auto; }
.panel { min-height: 38svh; }
}
3) JavaScript (interacción + accesibilidad)
const panels = Array.from(document.querySelectorAll(".panel"));
function activate(panel) {
panels.forEach(p => {
const isActive = p === panel;
p.classList.toggle("active", isActive);
p.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
panels.forEach(p => {
p.addEventListener("click", () => activate(p));
// soporte por teclado
p.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activate(p);
}
});
});
const panels = Array.from(document.querySelectorAll(".panel"));
function activate(panel) {
panels.forEach(p => {
const isActive = p === panel;
p.classList.toggle("active", isActive);
p.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
panels.forEach(p => {
p.addEventListener("click", () => activate(p));
// soporte por teclado
p.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activate(p);
}
});
});
Cómo correrlo: guarda los 3 archivos y abre index.html en tu navegador.
Buenas prácticas de accesibilidad (A11y)
-
Usa button (no div) para que sean focusables y anuncien interacción.
-
Añade role="list" al contenedor y role="listitem" a cada tarjeta para una estructura semántica.
-
Sin ratón, el usuario puede navegar con Tab y activar con Enter o Espacio.
-
Asegura contraste suficiente: el gradiente oscuro ayuda a que el texto se lea sobre la imagen.
Rendimiento y calidad de imagen
-
Prefiere imágenes optimizadas (formato AVIF/WebP si tu flujo lo permite).
-
Usa tamaños coherentes: 1600px de ancho suele ser suficiente para una imagen principal.
-
Si alojas las imágenes tú, añade loading="lazy" en <img> (si las usas) o sirve recursos con HTTP caching.
Finalmente debe quedar algo así