Un temporizador de 25 minutos de concentración y 5 de descanso que no se desajusta, cambia de modo solo, cuenta tus pomodoros y avisa con un sonido generado en el navegador.
5 pasos
1 hora aproximadamente
158 líneas de código
Editor en vivo
Lo que vas a construir
La técnica Pomodoro alterna 25 minutos de trabajo concentrado con 5 de descanso. Programar su temporizador enseña algo que sorprende: setInterval no es un reloj exacto. Vas a hacer una cuenta atrás que no se desajusta, con pausa, cambio automático de modo y un aviso sonoro creado con la Web Audio API, sin ningún archivo de audio.
Lo que vas a practicar
Temporizadores con setInterval
Calcular el tiempo con Date.now()
Un mismo botón con dos funciones
Estado con varios modos
Generar sonido con Web Audio
Resultado final
Antes de empezar, conviene haber hecho estas lecciones
Un rectángulo con el modo actual, el tiempo en grande y dos botones. font-variant-numeric: tabular-nums hace que todas las cifras ocupen lo mismo y el número no «baile» al cambiar.
El atributo data-modo del contenedor permitirá cambiar el color de fondo desde CSS según el modo.
formatear convierte segundos en «mm:ss» con padStart para que el 5 se vea como «05». setInterval ejecuta una función cada 250 milisegundos.
El truco está en no restar 1 en cada vuelta: setInterval puede retrasarse (sobre todo con la pestaña en segundo plano) y el reloj se iría desajustando. En su lugar se calcula la hora de fin y, en cada vuelta, cuánto falta hasta ella.
app.js
const tiempo = document.querySelector("#tiempo");
const botonEmpezar = document.querySelector("#empezar");
const DURACION = 25 * 60; // segundos
let restantes = DURACION;
let intervalo = null;
function formatear(segundos) {
const minutos = Math.floor(segundos / 60);
const resto = segundos % 60;
return `${String(minutos).padStart(2, "0")}:${String(resto).padStart(2, "0")}`;
}
function mostrar() {
tiempo.textContent = formatear(restantes);
}
function empezar() {
// Se calcula a partir de la hora de fin: setInterval no es exacto y acumularía retraso
const fin = Date.now() + restantes * 1000;
intervalo = setInterval(() => {
restantes = Math.max(0, Math.round((fin - Date.now()) / 1000));
mostrar();
if (restantes === 0) clearInterval(intervalo);
}, 250);
}
botonEmpezar.addEventListener("click", empezar);
mostrar();
3
Paso 3 de 5
Pausar y reiniciar
Pausar es detener el intervalo con clearInterval; como restantes conserva lo que quedaba, al seguir se calcula una nueva hora de fin desde ahí.
Un mismo botón empieza o pausa: si hay un intervalo en marcha (intervalo no es null), pausa; si no, empieza. Su texto cambia para que siempre diga lo que va a hacer.
app.jsLo nuevo, resaltado
const tiempo = document.querySelector("#tiempo");
const botonEmpezar = document.querySelector("#empezar");
const botonReiniciar = document.querySelector("#reiniciar");
const DURACION = 25 * 60; // segundos
let restantes = DURACION;
let intervalo = null;
function formatear(segundos) {
const minutos = Math.floor(segundos / 60);
const resto = segundos % 60;
return `${String(minutos).padStart(2, "0")}:${String(resto).padStart(2, "0")}`;
}
function mostrar() {
tiempo.textContent = formatear(restantes);
}
function empezar() {
const fin = Date.now() + restantes * 1000;
intervalo = setInterval(() => {
restantes = Math.max(0, Math.round((fin - Date.now()) / 1000));
mostrar();
if (restantes === 0) pausar();
}, 250);
botonEmpezar.textContent = "Pausar";
}
function pausar() {
clearInterval(intervalo);
intervalo = null;
botonEmpezar.textContent = "Seguir";
}
function reiniciar() {
pausar();
restantes = DURACION;
botonEmpezar.textContent = "Empezar";
mostrar();
}
// El mismo botón empieza o pausa según si el temporizador está en marcha
botonEmpezar.addEventListener("click", () => (intervalo ? pausar() : empezar()));
botonReiniciar.addEventListener("click", reiniciar);
mostrar();
4
Paso 4 de 5
Trabajo y descanso
Los dos modos se guardan en un objeto con su nombre y su duración. Al llegar a cero, terminar cuenta el pomodoro (si era de trabajo), cambia al otro modo y deja el temporizador listo.
pomodoro.dataset.modo = modo cambia el atributo data-modo, y el CSS pinta el fondo de rojo o de verde con una transición suave.
app.jsLo nuevo, resaltado
const tiempo = document.querySelector("#tiempo");
const botonEmpezar = document.querySelector("#empezar");
const botonReiniciar = document.querySelector("#reiniciar");
const pomodoro = document.querySelector(".pomodoro");
const textoModo = document.querySelector("#modo");
const contador = document.querySelector("#contador");
const MODOS = {
trabajo: { nombre: "Concentración", segundos: 25 * 60 },
descanso: { nombre: "Descanso", segundos: 5 * 60 },
};
let modo = "trabajo";
let restantes = MODOS[modo].segundos;
let intervalo = null;
let completados = 0;
function formatear(segundos) {
const minutos = Math.floor(segundos / 60);
const resto = segundos % 60;
return `${String(minutos).padStart(2, "0")}:${String(resto).padStart(2, "0")}`;
}
function mostrar() {
tiempo.textContent = formatear(restantes);
textoModo.textContent = MODOS[modo].nombre;
pomodoro.dataset.modo = modo;
}
function empezar() {
const fin = Date.now() + restantes * 1000;
intervalo = setInterval(() => {
restantes = Math.max(0, Math.round((fin - Date.now()) / 1000));
mostrar();
if (restantes === 0) terminar();
}, 250);
botonEmpezar.textContent = "Pausar";
}
function pausar() {
clearInterval(intervalo);
intervalo = null;
botonEmpezar.textContent = "Seguir";
}
function terminar() {
pausar();
if (modo === "trabajo") {
completados += 1;
contador.textContent = `Pomodoros completados: ${completados}`;
}
// Cambia de modo y deja el temporizador listo para empezar
modo = modo === "trabajo" ? "descanso" : "trabajo";
restantes = MODOS[modo].segundos;
botonEmpezar.textContent = "Empezar";
mostrar();
}
function reiniciar() {
pausar();
restantes = MODOS[modo].segundos;
botonEmpezar.textContent = "Empezar";
mostrar();
}
botonEmpezar.addEventListener("click", () => (intervalo ? pausar() : empezar()));
botonReiniciar.addEventListener("click", reiniciar);
mostrar();
5
Paso 5 de 5
Aviso sonoro y título de la pestaña
La Web Audio API genera sonido en el navegador: un oscilador produce un tono (880 Hz) y un nodo de volumen lo apaga poco a poco. Tres pitidos seguidos avisan del final, sin descargar ningún archivo.
Los navegadores solo permiten sonido después de que la persona interactúe con la página, por eso el AudioContext se crea al pulsar «Empezar» (??= lo crea solo la primera vez). El tiempo también se ve en el título de la pestaña.
app.jsLo nuevo, resaltado
const tiempo = document.querySelector("#tiempo");
const botonEmpezar = document.querySelector("#empezar");
const botonReiniciar = document.querySelector("#reiniciar");
const pomodoro = document.querySelector(".pomodoro");
const textoModo = document.querySelector("#modo");
const contador = document.querySelector("#contador");
const MODOS = {
trabajo: { nombre: "Concentración", segundos: 25 * 60 },
descanso: { nombre: "Descanso", segundos: 5 * 60 },
};
let modo = "trabajo";
let restantes = MODOS[modo].segundos;
let intervalo = null;
let completados = 0;
let audio = null; // el contexto de sonido se crea con el primer clic (los navegadores lo exigen)
function pitar() {
if (!audio) return;
// Tres pitidos cortos con un oscilador: no hace falta ningún archivo de sonido
for (let i = 0; i < 3; i++) {
const inicio = audio.currentTime + i * 0.35;
const oscilador = audio.createOscillator();
const volumen = audio.createGain();
oscilador.frequency.value = 880;
volumen.gain.setValueAtTime(0.25, inicio);
volumen.gain.exponentialRampToValueAtTime(0.001, inicio + 0.25);
oscilador.connect(volumen).connect(audio.destination);
oscilador.start(inicio);
oscilador.stop(inicio + 0.25);
}
}
function formatear(segundos) {
const minutos = Math.floor(segundos / 60);
const resto = segundos % 60;
return `${String(minutos).padStart(2, "0")}:${String(resto).padStart(2, "0")}`;
}
function mostrar() {
tiempo.textContent = formatear(restantes);
document.title = `${formatear(restantes)} · ${MODOS[modo].nombre}`; // se ve en la pestaña
textoModo.textContent = MODOS[modo].nombre;
pomodoro.dataset.modo = modo;
}
function empezar() {
audio ??= new AudioContext();
const fin = Date.now() + restantes * 1000;
intervalo = setInterval(() => {
restantes = Math.max(0, Math.round((fin - Date.now()) / 1000));
mostrar();
if (restantes === 0) terminar();
}, 250);
botonEmpezar.textContent = "Pausar";
}
function pausar() {
clearInterval(intervalo);
intervalo = null;
botonEmpezar.textContent = "Seguir";
}
function terminar() {
pausar();
pitar();
if (modo === "trabajo") {
completados += 1;
contador.textContent = `Pomodoros completados: ${completados}`;
}
// Cambia de modo y deja el temporizador listo para empezar
modo = modo === "trabajo" ? "descanso" : "trabajo";
restantes = MODOS[modo].segundos;
botonEmpezar.textContent = "Empezar";
mostrar();
}
function reiniciar() {
pausar();
restantes = MODOS[modo].segundos;
botonEmpezar.textContent = "Empezar";
mostrar();
}
botonEmpezar.addEventListener("click", () => (intervalo ? pausar() : empezar()));
botonReiniciar.addEventListener("click", reiniciar);
mostrar();
Editor en vivo
Cambia el código y mira el resultado al momento. Lo que escribas aquí no se guarda: usa «Descargar» para quedarte con los archivos.
Consola
Retos para seguir
Has terminado el proyecto. Si quieres ir más allá, prueba a añadirle esto por tu cuenta:
Tras 4 pomodoros, haz un descanso largo de 15 minutos.
Permite cambiar las duraciones con dos campos numéricos.
Muestra un aviso del sistema al terminar con la Notification API (pide permiso primero).
Con tu permiso usamos Google Analytics, que guarda cookies, para saber cuántas personas visitan la web y qué páginas les resultan útiles. No usamos cookies de publicidad y tus archivos nunca salen de tu navegador. Puedes cambiar de opinión cuando quieras en «Configurar cookies», al pie de la página. Más información.