Skip to content

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:

Sitio completo de Estudio Nova

Plan de trabajo ​

Un profesional no empieza escribiendo CSS al azar. Sigue este orden:

  1. Contenido y estructura: HTML semántico de todas las secciones, sin estilos.
  2. Tokens: colores, tipografía, espaciados y radios como variables.
  3. Base: reset, tipografía general, foco visible.
  4. Layout: contenedor y secciones.
  5. Componentes: uno por uno, en BEM, primero en móvil.
  6. Responsive: media queries con min-width al final.
  7. 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, optimizadas

En 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/height y loading="lazy" (excepto las del primer pantallazo).
  • [ ] CSS y JS minificados; sin librerías que no se usan.
  • [ ] Fuentes con font-display: swap y sólo los pesos necesarios.

Accesibilidad

  • [ ] Un solo h1 y 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 y prefers-reduced-motion respetado.

SEO

  • [ ] title y meta description únicos y descriptivos.
  • [ ] lang="es", URLs amigables, Open Graph para compartir en redes.
  • [ ] favicon, sitemap.xml y robots.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 ​

  1. 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.
  2. 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.
  3. Agrega un botón de modo oscuro que alterne una clase tema-oscuro en html y redefina las variables de color.
  4. 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 button real, aria-expanded, cierre con Escape y foco controlado.
  • details/summary para FAQ sin JS; :user-invalid y role="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.

Academia Web — aprende, copia y construye.