Proyecto: tu portfolio de desarrollador web con CSS
En esta lección
- Qué cambia respecto a la landing
- Planificar antes de escribir
- Paso 1: el HTML
- Paso 2: los colores de los dos temas
- Paso 3: tipografía fluida y espacios
- Paso 4: cabecera pegajosa
- Paso 5: el héroe con animación de entrada
- Paso 6: botones
- Paso 7: “Sobre mí” con áreas de Grid
- Paso 8: las etiquetas de tecnologías
- Paso 9: la rejilla de proyectos
- Paso 10: contacto y pie
- Paso 11: respetar a quien no quiere movimiento
- Paso 12: el botón de tema
- Probarlo y publicarlo
- Ideas para ampliarlo
- Errores frecuentes
- Resumen
Un portfolio es tu carta de presentación como desarrollador: una página con quién eres, qué sabes hacer y qué proyectos has construido. Es lo primero que mirará una empresa cuando busques prácticas o tu primer trabajo, y además es en sí mismo una prueba de tu nivel de CSS. En este proyecto vas a maquetar el portfolio de una desarrolladora ficticia, Marta, paso a paso. Usarás variables, Grid con áreas con nombre, Flexbox, modo oscuro con light-dark(), tipografía fluida y animaciones que respetan a quien prefiere menos movimiento.
Qué cambia respecto a la landing
En el Proyecto: landing ya maquetaste una página con cabecera, héroe, tarjetas y formulario, con una media query a 700px. Aquí no vamos a repetir eso. Este proyecto se centra en técnicas distintas:
| Landing (proyecto anterior) | Portfolio (este proyecto) |
|---|---|
| Colores fijos en variables | Tema claro y oscuro con light-dark() y un botón |
| Tarjetas en 1 o 3 columnas con media query | Rejilla automática con auto-fit y minmax() |
| Flexbox para imagen y texto | Grid con grid-template-areas para la sección “Sobre mí” |
| Un título fluido | Escala tipográfica fluida completa con variables |
| Transición en el botón | Animación de entrada y tarjetas que reaccionan al ratón |
| Cabecera normal | Cabecera pegajosa con fondo translúcido |
Planificar antes de escribir
Antes de abrir el editor, haz un boceto en papel con las secciones y cómo se colocan en móvil y en pantalla ancha:
- Cabecera fija arriba: nombre a la izquierda; menú y botón de tema a la derecha.
- Héroe: saludo, una frase que diga a qué te dedicas y dos botones (ver proyectos y descargar CV).
- Sobre mí: foto, texto y una lista de tecnologías. En móvil, uno debajo de otro; en pantalla ancha, foto a la izquierda y texto a la derecha.
- Proyectos: tarjetas con imagen, título, descripción, etiquetas y enlaces.
- Contacto: una frase y enlaces a correo, GitHub y LinkedIn.
- Pie.
Decide también la paleta: un fondo, una superficie (para las tarjetas), el color del texto, un gris para textos secundarios y un color de acento (el que destaca: botones, enlaces). Para cada uno necesitas su versión clara y su versión oscura. Aquí usaremos un verde azulado.
Crea una carpeta portfolio con index.html, estilos.css, tema.js y una carpeta img para las capturas de tus proyectos.
Paso 1: el HTML
Igual que en cualquier maquetación, primero el HTML semántico completo. Cada sección tiene un id para que el menú pueda enlazar a ella.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Marta Ruiz | Desarrolladora web</title>
<meta name="description" content="Portfolio de Marta Ruiz, desarrolladora web junior en Santander.">
<link rel="stylesheet" href="estilos.css">
<script src="tema.js" defer></script>
</head>
<body>
<header class="cabecera">
<div class="contenedor cabecera__interior">
<a class="logo" href="#inicio">marta.dev</a>
<nav class="menu" aria-label="Principal">
<a href="#sobre-mi">Sobre mí</a>
<a href="#proyectos">Proyectos</a>
<a href="#contacto">Contacto</a>
</nav>
<button class="boton-tema" id="boton-tema" type="button" aria-label="Cambiar tema">◐</button>
</div>
</header>
<main id="inicio">
<section class="heroe contenedor">
<p class="heroe__saludo">Hola, soy Marta Ruiz</p>
<h1 class="heroe__titulo">Desarrollo webs rápidas, accesibles y fáciles de usar</h1>
<p class="heroe__texto">Estudio DAW y busco empresa para hacer mis prácticas.</p>
<div class="heroe__botones">
<a class="boton" href="#proyectos">Ver proyectos</a>
<a class="boton boton--borde" href="cv-marta-ruiz.pdf" download>Descargar CV</a>
</div>
</section>
<section class="seccion contenedor" id="sobre-mi">
<h2 class="seccion__titulo">Sobre mí</h2>
<div class="sobre-mi">
<img class="sobre-mi__foto" src="img/marta.jpg" alt="Marta sonriendo delante de un portátil"
width="400" height="400">
<div class="sobre-mi__texto">
<p>Empecé a programar haciendo la web del club de senderismo de mi pueblo.
Me gusta que las páginas carguen rápido y que cualquiera pueda usarlas.</p>
<p>Ahora estoy aprendiendo TypeScript y bases de datos.</p>
</div>
<ul class="sobre-mi__tecnologias etiquetas">
<li>HTML</li><li>CSS</li><li>JavaScript</li><li>Git</li><li>Astro</li><li>SQL</li>
</ul>
</div>
</section>
<section class="seccion contenedor" id="proyectos">
<h2 class="seccion__titulo">Proyectos</h2>
<div class="proyectos">
<article class="proyecto">
<img class="proyecto__imagen" src="img/pistas.jpg" alt="" width="640" height="400">
<div class="proyecto__cuerpo">
<h3 class="proyecto__titulo">Reservas de pistas</h3>
<p>App para reservar pistas de pádel del polideportivo municipal.</p>
<ul class="etiquetas"><li>HTML</li><li>CSS</li><li>JavaScript</li></ul>
<div class="proyecto__enlaces">
<a href="#">Ver web</a>
<a href="#">Código</a>
</div>
</div>
</article>
<!-- Repite <article class="proyecto"> para cada proyecto -->
</div>
</section>
<section class="seccion contenedor contacto" id="contacto">
<h2 class="seccion__titulo">¿Hablamos?</h2>
<p>Escríbeme si buscas a alguien para prácticas o un proyecto pequeño.</p>
<a class="boton" href="mailto:hola@marta.dev">hola@marta.dev</a>
<ul class="redes">
<li><a href="https://github.com/">GitHub</a></li>
<li><a href="https://www.linkedin.com/">LinkedIn</a></li>
</ul>
</section>
</main>
<footer class="pie">
<p>© 2026 Marta Ruiz · Hecho a mano con HTML y CSS</p>
</footer>
</body>
</html>
Fíjate en dos detalles. La imagen de cada proyecto tiene alt="" porque el título de la tarjeta ya dice qué es; repetirlo solo haría que el lector de pantalla lo leyera dos veces. Y el script se carga con defer en el <head>: así se descarga pronto pero se ejecuta cuando el HTML ya está listo.
Paso 2: los colores de los dos temas
En lugar de definir los colores dos veces, usa la función light-dark(claro, oscuro): devuelve el primer valor si el tema es claro y el segundo si es oscuro. Para que funcione, el elemento debe declarar color-scheme: light dark. Lo tienes explicado con calma en Modo oscuro.
/* ---------- Tema ---------- */
:root {
color-scheme: light dark;
--fondo: light-dark(#fbfaf7, #14161b);
--superficie: light-dark(#ffffff, #1f2229);
--texto: light-dark(#1d1f24, #e8e8ea);
--suave: light-dark(#5d6370, #a5abb6);
--acento: light-dark(#0f766e, #5eead4);
--borde: light-dark(#e3e1da, #2e323b);
--sombra: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
}
/* Si el usuario elige un tema con el botón, se impone al del sistema */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
Sin hacer nada más, la página ya sigue la preferencia del sistema operativo. El atributo data-theme, que pondrá el JavaScript, permite forzar un tema concreto.
Consejo: comprueba el contraste del acento en los dos temas. Un verde que se lee bien sobre blanco suele ser demasiado oscuro sobre negro; por eso el oscuro usa un tono más claro (
#5eead4).
Paso 3: tipografía fluida y espacios
En vez de un único título fluido, define una escala tipográfica completa: unas pocas variables de tamaño que crecen con la pantalla gracias a clamp(mínimo, preferido, máximo). Haz lo mismo con los espacios. Así todo el diseño respira más en pantallas grandes sin escribir media queries.
:root {
/* Tamaños de letra */
--texto-sm: clamp(0.85rem, 0.8rem + 0.2vw, 0.95rem);
--texto-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--texto-lg: clamp(1.25rem, 1.1rem + 0.6vw, 1.6rem);
--texto-xl: clamp(2rem, 1.3rem + 3.5vw, 3.75rem);
/* Espacios */
--espacio-s: clamp(0.5rem, 0.4rem + 0.5vw, 0.75rem);
--espacio-m: clamp(1rem, 0.8rem + 1vw, 1.5rem);
--espacio-l: clamp(2.5rem, 1.5rem + 5vw, 6rem);
--radio: 12px;
--ancho: 1100px;
}
/* ---------- Base ---------- */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
font-size: var(--texto-base);
line-height: 1.6;
background: var(--fondo);
color: var(--texto);
}
h1, h2, h3 { line-height: 1.15; text-wrap: balance; }
img { display: block; max-width: 100%; height: auto; }
a { color: var(--acento); }
.contenedor {
width: min(100% - 2 * var(--espacio-m), var(--ancho));
margin-inline: auto;
}
.seccion { padding-block: var(--espacio-l); }
.seccion__titulo { font-size: var(--texto-lg); margin: 0 0 var(--espacio-m); }
Los tamaños mínimos están en rem para que respeten el tamaño de letra que el usuario haya elegido en su navegador. text-wrap: balance reparte las palabras de los títulos en líneas de longitud parecida, para que no quede una palabra sola en la última línea.
Paso 4: cabecera pegajosa
La cabecera se queda pegada arriba al hacer scroll con position: sticky. Le damos un fondo semitransparente con desenfoque para que el contenido se intuya por debajo. Para la fila interior, Flexbox.
/* ---------- Cabecera ---------- */
.cabecera {
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--fondo) 85%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--borde);
}
.cabecera__interior {
display: flex;
align-items: center;
gap: var(--espacio-m);
padding-block: var(--espacio-s);
}
.logo {
margin-right: auto; /* empuja el resto a la derecha */
color: var(--texto);
font-weight: 700;
text-decoration: none;
}
.menu { display: none; gap: var(--espacio-m); }
.menu a { color: var(--suave); text-decoration: none; }
.menu a:hover, .menu a:focus-visible { color: var(--acento); }
.boton-tema {
width: 44px;
height: 44px;
border: 1px solid var(--borde);
border-radius: 50%;
background: var(--superficie);
color: var(--texto);
font-size: 1.2rem;
cursor: pointer;
}
@media (width >= 640px) {
.menu { display: flex; }
}
/* Que los títulos no queden tapados por la cabecera al saltar a un ancla */
html { scroll-behavior: smooth; }
section[id] { scroll-margin-top: 80px; }
margin-right: auto en el logo es un truco clásico de Flexbox: el margen automático se come todo el espacio sobrante y empuja los demás elementos al otro extremo. color-mix() mezcla el color de fondo con transparente para conseguir el 85 % de opacidad sin escribir otro color. Más sobre sticky y scroll-margin-top en Scroll.
Nota: en móvil ocultamos el menú para simplificar. En un portfolio de una sola página basta con hacer scroll; si quieres un menú desplegable, lo puedes construir con
<details>o con JavaScript.
Paso 5: el héroe con animación de entrada
/* ---------- Héroe ---------- */
.heroe { padding-block: var(--espacio-l); }
.heroe__saludo {
color: var(--acento);
font-weight: 600;
margin: 0;
}
.heroe__titulo {
font-size: var(--texto-xl);
max-width: 20ch;
margin: var(--espacio-s) 0 var(--espacio-m);
}
.heroe__texto { color: var(--suave); max-width: 50ch; }
.heroe__botones {
display: flex;
flex-wrap: wrap;
gap: var(--espacio-s);
margin-top: var(--espacio-m);
}
/* Animación de entrada escalonada */
.heroe > * {
animation: aparecer 0.7s ease-out both;
}
.heroe > :nth-child(2) { animation-delay: 0.1s; }
.heroe > :nth-child(3) { animation-delay: 0.2s; }
.heroe > :nth-child(4) { animation-delay: 0.3s; }
@keyframes aparecer {
from {
opacity: 0;
translate: 0 16px;
}
}
Tres ideas importantes aquí:
max-width: 20chlimita el título a unos 20 caracteres por línea. La unidadchequivale al ancho del carácter “0”, así que es perfecta para controlar la longitud de las líneas de texto.- En
@keyframessolo escribimosfrom: el navegador usa comotoel estado normal del elemento. Así no repites los valores finales. both(enanimation-fill-mode) hace que, durante el retraso, el elemento ya esté en el estadofrom. Sin él, verías el texto un instante, desaparecería y luego se animaría.
Animamos solo opacity y translate porque el navegador los mueve sin recalcular el diseño de la página. Todo esto está explicado en Animaciones.
Paso 6: botones
/* ---------- Botones ---------- */
.boton {
display: inline-block;
padding: 0.7em 1.4em;
border: 2px solid var(--acento);
border-radius: var(--radio);
background: var(--acento);
color: var(--fondo);
font-weight: 600;
text-decoration: none;
transition: background-color 0.2s, color 0.2s, translate 0.2s;
}
.boton:hover { translate: 0 -2px; }
.boton--borde {
background: transparent;
color: var(--acento);
}
.boton--borde:hover {
background: var(--acento);
color: var(--fondo);
}
:focus-visible {
outline: 3px solid var(--acento);
outline-offset: 3px;
}
El texto del botón usa var(--fondo) como color: en tema claro será casi blanco y en oscuro casi negro, siempre con buen contraste sobre el acento. Un único :focus-visible global da a todos los enlaces y botones un contorno visible al navegar con teclado.
Paso 7: “Sobre mí” con áreas de Grid
Esta sección tiene tres piezas: foto, texto y lista de tecnologías. En móvil van una debajo de otra. En pantalla ancha, la foto ocupa una columna a la izquierda y las otras dos se apilan a su derecha. Con grid-template-areas dibujas esa distribución con nombres, casi como un mapa:
/* ---------- Sobre mí ---------- */
.sobre-mi {
display: grid;
gap: var(--espacio-m);
grid-template-areas:
"foto"
"texto"
"tecnologias";
}
.sobre-mi__foto { grid-area: foto; }
.sobre-mi__texto { grid-area: texto; }
.sobre-mi__tecnologias { grid-area: tecnologias; }
.sobre-mi__foto {
width: min(100%, 280px);
aspect-ratio: 1;
object-fit: cover;
border-radius: 50%;
}
.sobre-mi__texto p { margin-top: 0; }
@media (width >= 760px) {
.sobre-mi {
grid-template-columns: 280px 1fr;
grid-template-areas:
"foto texto"
"foto tecnologias";
align-items: start;
}
}
Lo bueno de las áreas es que, para cambiar la distribución, solo reescribes el “mapa” dentro de la media query; los elementos no se tocan. Repasa la sintaxis en Grid.
Paso 8: las etiquetas de tecnologías
Las etiquetas (las “píldoras” con HTML, CSS…) aparecen en “Sobre mí” y en cada proyecto, así que son un componente reutilizable:
/* ---------- Etiquetas ---------- */
.etiquetas {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.etiquetas li {
font-size: var(--texto-sm);
padding: 2px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--acento) 15%, transparent);
color: var(--texto);
}
flex-wrap: wrap hace que las etiquetas bajen de línea cuando no caben. border-radius: 999px es la forma habitual de conseguir extremos totalmente redondos sea cual sea la altura.
Paso 9: la rejilla de proyectos
Aquí no queremos decidir a mano cuántas columnas hay. Con auto-fit y minmax(), Grid mete tantas columnas de al menos 280px como quepan y reparte el espacio sobrante:
/* ---------- Proyectos ---------- */
.proyectos {
display: grid;
gap: var(--espacio-m);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
}
.proyecto {
display: flex;
flex-direction: column;
background: var(--superficie);
border: 1px solid var(--borde);
border-radius: var(--radio);
overflow: hidden; /* recorta la imagen a las esquinas redondeadas */
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.proyecto:hover {
transform: translateY(-6px);
box-shadow: 0 12px 28px var(--sombra);
}
.proyecto__imagen {
aspect-ratio: 16 / 10;
width: 100%;
object-fit: cover;
}
.proyecto__cuerpo {
display: flex;
flex-direction: column;
gap: var(--espacio-s);
flex: 1; /* el cuerpo ocupa todo el alto disponible */
padding: var(--espacio-m);
}
.proyecto__titulo { font-size: var(--texto-lg); margin: 0; }
.proyecto__cuerpo p { margin: 0; color: var(--suave); }
.proyecto__enlaces {
display: flex;
gap: var(--espacio-m);
margin-top: auto; /* empuja los enlaces al fondo de la tarjeta */
}
¿Por qué min(100%, 280px) y no 280px a secas? En un móvil de 320px, con los márgenes, el contenedor mide menos de 280px; una columna de 280px se saldría y provocaría scroll horizontal. Con min(), la columna nunca es más ancha que su contenedor.
La tarjeta es un Flexbox en columna y su cuerpo tiene flex: 1. Como Grid estira todas las tarjetas de una fila a la misma altura, el margin-top: auto de los enlaces los manda abajo del todo: quedan alineados aunque una descripción sea más larga que otra. Los enlaces van en un <div> y no en un <p> a propósito: si fuera un párrafo, la regla .proyecto__cuerpo p { margin: 0 }, que tiene más especificidad, anularía ese margin-top: auto.
Paso 10: contacto y pie
/* ---------- Contacto ---------- */
.contacto { text-align: center; }
.contacto p { color: var(--suave); }
.redes {
list-style: none;
padding: 0;
display: flex;
justify-content: center;
gap: var(--espacio-m);
margin-top: var(--espacio-m);
}
/* ---------- Pie ---------- */
.pie {
border-top: 1px solid var(--borde);
padding-block: var(--espacio-m);
text-align: center;
font-size: var(--texto-sm);
color: var(--suave);
}
Paso 11: respetar a quien no quiere movimiento
Algunas personas se marean con las animaciones y lo indican en su sistema operativo. La media query prefers-reduced-motion te lo dice. Pon esto al final del CSS:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
html { scroll-behavior: auto; }
}
Usamos una duración casi nula en lugar de animation: none para que los elementos lleguen igualmente a su estado final. Es uno de los pocos casos en que !important está justificado. Más en CSS accesible.
Paso 12: el botón de tema
El CSS ya hace casi todo. Solo falta un poco de JavaScript que ponga data-theme en <html> y lo recuerde para la próxima visita con localStorage:
// tema.js
const raiz = document.documentElement;
const guardado = localStorage.getItem('tema');
if (guardado) raiz.dataset.theme = guardado;
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#boton-tema').addEventListener('click', () => {
const sistemaOscuro = matchMedia('(prefers-color-scheme: dark)').matches;
const actual = raiz.dataset.theme ?? (sistemaOscuro ? 'dark' : 'light');
const nuevo = actual === 'dark' ? 'light' : 'dark';
raiz.dataset.theme = nuevo;
localStorage.setItem('tema', nuevo);
});
});
Si nunca se ha pulsado el botón, no hay data-theme y manda el sistema. Al pulsarlo, se calcula el tema actual y se cambia al contrario. Si aún no has llegado a JavaScript, no pasa nada: sin este archivo la página sigue funcionando y se adapta sola al tema del sistema.
Probarlo y publicarlo
Pegando en orden los bloques CSS de los pasos 2 a 11 tienes el estilos.css completo. Revisa:
- En DevTools, modo dispositivo, a 320px, 768px y 1280px: sin scroll horizontal y con la rejilla de proyectos cambiando de columnas sola.
- En la pestaña Rendering de DevTools puedes emular
prefers-color-scheme: darkyprefers-reduced-motion: reducesin tocar tu sistema. - Navega solo con el tabulador: cada enlace y botón debe mostrar su contorno.
Cuando te guste, súbelo gratis a GitHub Pages, Netlify o Cloudflare Pages y pon el enlace en tu CV.
Ideas para ampliarlo
- Filtros de proyectos: botones “Todos”, “CSS”, “JavaScript” que oculten tarjetas. Con CSS puro puedes hacerlo con casillas y
:has(); con JavaScript, añadiendo una clase. - Animación al hacer scroll: que cada tarjeta aparezca al entrar en pantalla con
animation-timeline: view()(mira Animaciones). - Línea de tiempo con tu formación y experiencia, usando un pseudoelemento
::beforecomo línea vertical. - Tarjetas con container queries: que la tarjeta cambie de vertical a horizontal según su propio ancho (Container queries).
- Hoja de impresión: que al imprimir la página salga como un CV limpio (CSS para imprimir).
Errores frecuentes
- Poner columnas fijas en la rejilla (
repeat(3, 280px)). En móvil desbordan. Usaauto-fitconminmax(min(100%, 280px), 1fr). - Olvidar
color-scheme. Sin él,light-dark()siempre devuelve el valor claro y los controles del navegador (barras de scroll, campos) no cambian de tema. - Comprobar el contraste solo en un tema. El acento que funciona en claro puede no leerse en oscuro.
- Animar
top,marginoheight. Obligan a recalcular el diseño y van a tirones. Animatransform,translateyopacity. - Animaciones sin
prefers-reduced-motion. Para algunas personas no son un adorno, son una molestia física. - Olvidar
scroll-margin-topcon una cabecera fija. Al pulsar en el menú, el título de la sección queda escondido detrás de la cabecera. - Llenarlo de proyectos de tutorial. Mejor tres proyectos propios bien explicados que diez copias.
Resumen
| Parte | Técnica principal |
|---|---|
| Tema claro y oscuro | color-scheme, light-dark() y data-theme |
| Tipografía y espacios | Escala de variables con clamp() |
| Cabecera | position: sticky, backdrop-filter, Flexbox con margin-right: auto |
| Héroe | @keyframes con from, retrasos escalonados, ch |
| Sobre mí | Grid con grid-template-areas que cambia en una media query |
| Proyectos | repeat(auto-fit, minmax(min(100%, 280px), 1fr)) |
| Tarjetas | Flexbox en columna y margin-top: auto |
| Accesibilidad | :focus-visible, prefers-reduced-motion |
Para poner a prueba todo lo aprendido, sigue con los Ejercicios de CSS.
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.