Tema
Proyecto final: sitio responsive
Llegó el momento de juntar todo lo que aprendiste en un proyecto real: el sitio de Estudio Nova, una agencia digital ficticia. Es el tipo de sitio que un cliente te pediría en tu primer trabajo freelance: header fijo con menú hamburguesa, hero, servicios, nosotros, testimonios, precios, preguntas frecuentes, formulario de contacto y footer. Lo construiremos mobile-first, con variables CSS, nombres BEM, animaciones sutiles que respetan prefers-reduced-motion y un poco de JavaScript accesible.
El resultado
Explora el sitio terminado. Cambia entre móvil, tablet y escritorio, abre el menú, despliega las preguntas y prueba el formulario. En las pestañas del demo tienes el código completo (HTML, CSS y JS) listo para copiar:
Plan de trabajo
Un profesional no empieza escribiendo CSS al azar. Sigue este orden:
- Contenido y estructura: HTML semántico de todas las secciones, sin estilos.
- Tokens: colores, tipografía, espaciados y radios como variables.
- Base: reset, tipografía general, foco visible.
- Layout: contenedor y secciones.
- Componentes: uno por uno, en BEM, primero en móvil.
- Responsive: media queries con
min-widthal final. - Detalles: animaciones, accesibilidad, rendimiento y pruebas.
txt
estudio-nova/
├── index.html
├── css/
│ └── estilos.css ← en un proyecto grande: base/, layout/, componentes/ (lección 15)
├── js/
│ └── main.js
└── img/ ← imágenes en WebP/AVIF, optimizadasEn el demo todo está en un solo archivo para que lo veas completo; en tu proyecto, sepáralo así.
Paso 1: estructura HTML semántica
Esqueleto general. Fíjate en el enlace "Saltar al contenido" (para usuarios de teclado), los aria-labelledby de las secciones y un solo h1:
html
<body>
<a class="saltar" href="#principal">Saltar al contenido</a>
<header class="header">...</header>
<main id="principal">
<section class="hero" id="inicio">...</section>
<section class="seccion" id="servicios" aria-labelledby="titulo-servicios">...</section>
<section class="seccion seccion--alt" id="nosotros" aria-labelledby="titulo-nosotros">...</section>
<section class="seccion" aria-labelledby="titulo-testimonios">...</section>
<section class="seccion seccion--alt" id="precios" aria-labelledby="titulo-precios">...</section>
<section class="seccion" id="faq" aria-labelledby="titulo-faq">...</section>
<section class="seccion seccion--alt" id="contacto" aria-labelledby="titulo-contacto">...</section>
</main>
<footer class="footer">...</footer>
</body>Paso 2: tokens con variables CSS
Todas las decisiones de diseño en un solo lugar. Si el cliente cambia su color de marca, cambias una línea (repasa Variables CSS):
css
:root {
--color-primario: #6d28d9;
--color-primario-oscuro: #4c1d95;
--color-acento: #f59e0b;
--color-texto: #0f172a;
--color-texto-suave: #475569;
--color-fondo-alt: #f5f3ff;
--color-borde: #e2e8f0;
/* Tipografía fluida con clamp(): sin media queries */
--texto-titulo: clamp(1.75rem, 4vw + 1rem, 2.5rem);
--texto-hero: clamp(2.1rem, 6vw + 1rem, 4rem);
--espacio-2: 1rem;
--espacio-4: 2rem;
--espacio-seccion: clamp(3.5rem, 8vw, 6rem);
--radio: 14px;
--sombra: 0 4px 20px rgb(15 23 42 / .08);
--transicion: .25s ease;
--alto-header: 64px;
}Paso 3: base y layout
css
*, *::before, *::after { box-sizing: border-box; }
html {
scroll-behavior: smooth;
scroll-padding-top: var(--alto-header); /* las anclas no quedan tapadas por el header fijo */
}
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.6; color: var(--color-texto); }
img { max-width: 100%; display: block; }
h1, h2, h3 { line-height: 1.15; text-wrap: balance; }
/* Foco visible y bonito para navegación con teclado */
:focus-visible { outline: 3px solid var(--color-acento); outline-offset: 3px; }
/* Layout */
.contenedor { width: min(100% - 2.5rem, 1120px); margin-inline: auto; }
.seccion { padding-block: var(--espacio-seccion); }
.seccion--alt { background: var(--color-fondo-alt); }min(100% - 2.5rem, 1120px) resuelve en una línea lo que antes requería max-width, width y padding.
Paso 4: header sticky con menú hamburguesa
html
<header class="header">
<div class="contenedor header__barra">
<a class="header__logo" href="#inicio">Estudio <span>Nova</span></a>
<!-- aria-expanded indica si el menú está abierto; aria-controls, qué controla -->
<button class="header__toggle" type="button" aria-expanded="false" aria-controls="menu-principal">
<span class="header__toggle-icono" aria-hidden="true"></span>
<span class="sr-only">Abrir menú</span>
</button>
<nav class="nav" id="menu-principal" aria-label="Principal">
<ul class="nav__lista">
<li><a class="nav__enlace" href="#servicios">Servicios</a></li>
<li><a class="nav__enlace" href="#nosotros">Nosotros</a></li>
<li><a class="nav__enlace" href="#precios">Precios</a></li>
<li><a class="nav__enlace" href="#faq">Preguntas</a></li>
<li class="nav__cta"><a class="boton boton--primario boton--bloque" href="#contacto">Cotizar</a></li>
</ul>
</nav>
</div>
</header>css
.header {
position: sticky; /* se queda arriba al hacer scroll */
top: 0;
z-index: 50;
background: rgb(255 255 255 / .9);
backdrop-filter: blur(10px); /* efecto vidrio */
border-bottom: 1px solid var(--color-borde);
}
.header__barra { display: flex; align-items: center; justify-content: space-between; min-height: var(--alto-header); }
/* Móvil: el menú es un panel que cuelga bajo el header, oculto por defecto */
.nav { position: absolute; inset: 100% 0 auto; display: none; background: #fff; }
.nav--abierto { display: block; }
/* La hamburguesa se convierte en X usando el propio aria-expanded */
.header__toggle[aria-expanded="true"] .header__toggle-icono { background: transparent; }
.header__toggle[aria-expanded="true"] .header__toggle-icono::before { transform: translateY(6px) rotate(45deg); }
.header__toggle[aria-expanded="true"] .header__toggle-icono::after { transform: translateY(-6px) rotate(-45deg); }
/* Escritorio: menú en fila y sin hamburguesa */
@media (min-width: 900px) {
.header__toggle { display: none; }
.nav { position: static; display: block; background: none; }
.nav__lista { display: flex; align-items: center; gap: .5rem; }
}El JavaScript mínimo, pero accesible: actualiza aria-expanded, cambia el texto para lectores de pantalla, cierra al elegir un enlace y con la tecla Escape:
js
const toggle = document.querySelector('.header__toggle')
const nav = document.getElementById('menu-principal')
const etiqueta = toggle.querySelector('.sr-only')
function cambiarMenu(abrir) {
toggle.setAttribute('aria-expanded', String(abrir))
nav.classList.toggle('nav--abierto', abrir)
etiqueta.textContent = abrir ? 'Cerrar menú' : 'Abrir menú'
}
toggle.addEventListener('click', () => {
cambiarMenu(toggle.getAttribute('aria-expanded') !== 'true')
})
// Cerrar al elegir un enlace
nav.addEventListener('click', (e) => {
if (e.target.closest('a')) cambiarMenu(false)
})
// Cerrar con Escape y devolver el foco al botón
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
cambiarMenu(false)
toggle.focus()
}
})Error común
Usar un div con onclick como botón de menú. No recibe foco con el teclado ni se anuncia como botón. Usa siempre un button real con type="button".
Paso 5: hero
css
.hero {
padding-block: clamp(4rem, 12vw, 8rem);
color: #fff;
background:
radial-gradient(circle at 85% 15%, rgb(245 158 11 / .35), transparent 40%), /* brillo */
linear-gradient(135deg, var(--color-primario), var(--color-primario-oscuro));
}
.hero__titulo { font-size: var(--texto-hero); letter-spacing: -.02em; }
.hero__acciones { display: flex; flex-wrap: wrap; gap: 1rem; } /* botones apilados si no caben */Paso 6: servicios en grid
Mobile-first: una columna por defecto, dos desde 640px y tres desde 900px (Grid a fondo):
css
.servicios { display: grid; gap: 1.5rem; }
.servicio {
padding: 2rem 1.5rem;
border: 1px solid var(--color-borde);
border-radius: var(--radio);
transition: transform var(--transicion), box-shadow var(--transicion);
}
.servicio:hover { transform: translateY(-4px); box-shadow: 0 18px 40px rgb(76 29 149 / .18); }
@media (min-width: 640px) { .servicios { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 900px) { .servicios { grid-template-columns: repeat(3, 1fr); } }Paso 7: nosotros y testimonios
"Nosotros" son dos columnas en escritorio (imagen y texto) y una en móvil. Los testimonios usan figure, blockquote y figcaption, la estructura semántica correcta para una cita con autor:
html
<figure class="testimonio">
<blockquote class="testimonio__cita">"Nuestras reservaciones en línea se triplicaron en tres meses."</blockquote>
<figcaption class="testimonio__autor">
<span class="testimonio__avatar" aria-hidden="true">AL</span>
<span><strong>Ana López</strong>Café Raíz</span>
</figcaption>
</figure>css
.nosotros { display: grid; gap: 2rem; align-items: center; }
.nosotros__item::before { content: "✓"; margin-right: .6rem; color: var(--color-primario); }
@media (min-width: 900px) {
.nosotros { grid-template-columns: 1fr 1fr; }
}Paso 8: precios con plan destacado
Un modificador BEM (plan--destacado) y el botón empujado al fondo con margin-top: auto para que todos queden alineados:
css
.plan {
position: relative;
display: flex;
flex-direction: column;
padding: 2rem 1.5rem;
border: 1px solid var(--color-borde);
border-radius: var(--radio);
}
.plan .boton { margin-top: auto; }
.plan--destacado { border: 2px solid var(--color-primario); }
.plan__badge { /* "Más elegido", centrado sobre el borde superior */
position: absolute;
top: 0;
left: 50%;
translate: -50% -50%;
background: var(--color-acento);
}
@media (min-width: 900px) {
.precios { grid-template-columns: repeat(3, 1fr); }
.plan--destacado { transform: scale(1.04); }
}Paso 9: FAQ con details
Las preguntas frecuentes no necesitan JavaScript: details y summary ya son un acordeón accesible. El atributo name (igual en todos) hace que sólo uno esté abierto a la vez en navegadores modernos:
html
<details class="faq__item" name="faq" open>
<summary class="faq__pregunta">¿Cuánto tarda mi sitio?</summary>
<p class="faq__respuesta">Entre 4 y 6 semanas, según el plan.</p>
</details>css
.faq__pregunta { display: flex; justify-content: space-between; cursor: pointer; list-style: none; }
.faq__pregunta::-webkit-details-marker { display: none; } /* oculta el triángulo en Safari */
.faq__pregunta::after { content: "+"; transition: transform var(--transicion); }
.faq__item[open] .faq__pregunta::after { transform: rotate(45deg); } /* el + se vuelve × */Paso 10: contacto con formulario
Cada campo con su label asociado, autocomplete para llenar más rápido en móvil, y :user-invalid para marcar errores sólo después de que la persona interactúa:
html
<form class="formulario" id="formulario" novalidate>
<div class="formulario__campo">
<label class="formulario__etiqueta" for="correo">Correo</label>
<input class="formulario__control" id="correo" name="correo" type="email" autocomplete="email" required>
</div>
<!-- ... nombre, plan y mensaje ... -->
<button class="boton boton--primario boton--bloque" type="submit">Enviar mensaje</button>
<!-- role="status": los lectores de pantalla anuncian el mensaje de éxito o error -->
<p class="formulario__mensaje" id="formulario-estado" role="status" aria-live="polite"></p>
</form>css
.formulario__control {
width: 100%;
padding: .75rem .9rem;
border: 2px solid var(--color-borde);
border-radius: 10px;
font: inherit; /* los inputs no heredan la fuente por defecto */
}
.formulario__control:focus { outline: none; border-color: var(--color-primario); }
.formulario__control:user-invalid { border-color: #dc2626; }En el demo el envío se simula. En un sitio real, conecta el formulario a un backend (lo harás en la sección de PHP) o a un servicio como Formspree.
Paso 11: animaciones sutiles y accesibles
Las animaciones sólo se activan si la persona no pidió reducir el movimiento en su sistema. Además, los elementos que aparecen al hacer scroll sólo se ocultan si hay JavaScript (clase js en html), así nadie se queda sin contenido:
css
@media (prefers-reduced-motion: no-preference) {
.hero__contenido > * { animation: subir .7s ease both; }
.hero__contenido > :nth-child(2) { animation-delay: .1s; }
.js .revelar { opacity: 0; translate: 0 24px; transition: opacity .6s, translate .6s; }
.js .revelar.is-visible { opacity: 1; translate: 0 0; }
}
@keyframes subir { from { opacity: 0; translate: 0 20px; } }js
document.documentElement.classList.add('js')
const observador = new IntersectionObserver((entradas) => {
entradas.forEach((entrada) => {
if (entrada.isIntersecting) {
entrada.target.classList.add('is-visible')
observador.unobserve(entrada.target) // anima sólo la primera vez
}
})
}, { threshold: 0.15 })
document.querySelectorAll('.revelar').forEach((el) => observador.observe(el))Buena práctica
"Sutil" es la palabra clave: movimientos cortos (menos de 20-30px), rápidos (0.2-0.7s) y sólo con opacity y transform/translate, que no provocan recálculos de layout. Si una animación distrae del contenido, sobra.
Checklist de entrega a cliente
Antes de decir "ya quedó", revisa cada punto:
Rendimiento
- [ ] Imágenes en WebP/AVIF, con
width/heightyloading="lazy"(excepto las del primer pantallazo). - [ ] CSS y JS minificados; sin librerías que no se usan.
- [ ] Fuentes con
font-display: swapy sólo los pesos necesarios.
Accesibilidad
- [ ] Un solo
h1y jerarquía de encabezados ordenada. - [ ] Todas las imágenes con
alt(vacío si son decorativas). - [ ] Navegable al 100 % con teclado (Tab, Enter, Escape) y foco siempre visible.
- [ ] Contraste suficiente (4.5:1 en texto normal).
- [ ] Formularios con
label, mensajes de error claros yprefers-reduced-motionrespetado.
SEO
- [ ]
titleymeta descriptionúnicos y descriptivos. - [ ]
lang="es", URLs amigables, Open Graph para compartir en redes. - [ ]
favicon,sitemap.xmlyrobots.txt.
Pruebas
- [ ] Modo responsive de DevTools en 360, 768, 1024 y 1440px: sin scroll horizontal.
- [ ] Celular Android y iPhone reales (Safari tiene sus propias sorpresas).
- [ ] Chrome, Firefox, Safari y Edge.
- [ ] Todos los enlaces y el formulario funcionan; textos sin errores de ortografía.
Lighthouse
- [ ] En Chrome DevTools, pestaña Lighthouse, analiza en modo móvil. Apunta a 90+ en Rendimiento, Accesibilidad, Buenas prácticas y SEO, y corrige lo que te marque.
Entrega profesional
Junto con el sitio entrega al cliente: accesos (hosting, dominio, panel), un manual breve o video de cómo editar contenido, y un respaldo. Eso convierte un trabajo en un cliente recurrente.
Ejercicios
- Cambia la identidad del sitio a la de una cafetería: modifica sólo los tokens de color y tipografía y comprueba que todo el sitio se adapta.
- Añade una sección "Proyectos" con una galería de 6 tarjetas usando container queries para que cada tarjeta cambie a diseño horizontal cuando su contenedor mida más de 420px.
- Agrega un botón de modo oscuro que alterne una clase
tema-oscuroenhtmly redefina las variables de color. - Corre Lighthouse sobre tu versión y lleva todas las categorías a 90 o más.
Ver solución
css
/* 1: sólo tokens */
:root {
--color-primario: #92400e;
--color-primario-oscuro: #451a03;
--color-acento: #fbbf24;
--color-fondo-alt: #fffbeb;
--fuente: "Georgia", serif;
}
/* 2 */
.proyectos { display: grid; gap: 1.5rem; }
.proyecto-envoltura { container-type: inline-size; }
.proyecto { display: grid; gap: 1rem; }
@container (min-width: 420px) {
.proyecto { grid-template-columns: 40% 1fr; align-items: center; }
}
@media (min-width: 900px) {
.proyectos { grid-template-columns: repeat(2, 1fr); }
}
/* 3 */
.tema-oscuro {
--color-texto: #f1f5f9;
--color-texto-suave: #cbd5e1;
--color-fondo: #0f172a;
--color-fondo-alt: #1e1b4b;
--color-borde: #334155;
}js
// 3
document.querySelector('#boton-tema').addEventListener('click', () => {
document.documentElement.classList.toggle('tema-oscuro')
})Para el ejercicio 3, recuerda que las tarjetas con background: #fff deben usar una variable (por ejemplo --color-superficie) para que también cambien.
Resumen
- Orden profesional: contenido → tokens → base → layout → componentes → responsive → detalles.
- Variables CSS para todo el diseño, BEM para los nombres y mobile-first con
min-width. - Menú hamburguesa con
buttonreal,aria-expanded, cierre con Escape y foco controlado. details/summarypara FAQ sin JS;:user-invalidyrole="status"en formularios.- Animaciones sutiles dentro de
prefers-reduced-motion: no-preference. - Antes de entregar: rendimiento, accesibilidad, SEO, pruebas en dispositivos y Lighthouse.
¡Felicidades!
Terminaste el curso de CSS. Ya sabes maquetar cualquier diseño, hacerlo responsive, organizarlo como profesional y entregarlo listo para un cliente. Eso es muchísimo. Practica rehaciendo sitios que te gusten y experimenta en el Playground.
Siguiente: dale vida e interactividad a tus sitios en el curso de JavaScript.