Proyectos · JavaScript · Básico

Calculadora

Una calculadora que funciona con el ratón y con el teclado, sin usar eval: botones en rejilla, operaciones encadenadas y errores bien tratados.

  • 6 pasos
  • 1 hora aproximadamente
  • 176 líneas de código
  • Editor en vivo

Lo que vas a construir

Vas a construir una calculadora completa partiendo de cero. Es el proyecto perfecto para practicar el DOM y los eventos: cada botón es un elemento HTML, CSS Grid los coloca en su sitio y JavaScript decide qué hacer con cada pulsación, sin recurrir a eval, que ejecutaría cualquier texto como código.

Lo que vas a practicar

  • Colocar botones con CSS Grid
  • Escuchar clics con un solo evento (delegación)
  • Guardar el estado en variables
  • Responder al teclado
  • Evitar errores de los decimales
Resultado final

Antes de empezar, conviene haber hecho estas lecciones

Paso 1 de 6

La estructura en HTML

Empieza por el HTML: una pantalla (<output>, que los lectores de pantalla anuncian al cambiar) y un botón por tecla. Cada botón lleva un atributo data- que JavaScript leerá después: data-numero para las cifras, data-operador para las operaciones y data-accion para el resto.

Los símbolos ÷, × y − no se leen bien en voz alta, así que esos botones llevan aria-label con su nombre. Los archivos de estilos y de JavaScript están vacíos de momento.

index.html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Calculadora</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="calculadora">
    <output class="pantalla" id="pantalla" aria-live="polite">0</output>
    <div class="teclas">
      <button data-accion="borrar" class="tecla tecla--gris">C</button>
      <button data-accion="atras" class="tecla tecla--gris" aria-label="Borrar la última cifra">←</button>
      <button data-operador="/" class="tecla tecla--op" aria-label="Dividir">÷</button>
      <button data-operador="*" class="tecla tecla--op" aria-label="Multiplicar">×</button>
      <button data-numero="7" class="tecla">7</button>
      <button data-numero="8" class="tecla">8</button>
      <button data-numero="9" class="tecla">9</button>
      <button data-operador="-" class="tecla tecla--op" aria-label="Restar">−</button>
      <button data-numero="4" class="tecla">4</button>
      <button data-numero="5" class="tecla">5</button>
      <button data-numero="6" class="tecla">6</button>
      <button data-operador="+" class="tecla tecla--op" aria-label="Sumar">+</button>
      <button data-numero="1" class="tecla">1</button>
      <button data-numero="2" class="tecla">2</button>
      <button data-numero="3" class="tecla">3</button>
      <button data-accion="igual" class="tecla tecla--igual">=</button>
      <button data-numero="0" class="tecla tecla--ancha">0</button>
      <button data-numero="." class="tecla" aria-label="Coma decimal">,</button>
    </div>
  </main>
  <script src="app.js"></script>
</body>
</html>
estilos.css
/* Los estilos llegan en el paso 2 */
app.js
// El JavaScript llega en el paso 3

Paso 2 de 6

La rejilla de botones con CSS

Con display: grid y grid-template-columns: repeat(4, 1fr), los botones se colocan solos en 4 columnas iguales. Para que el «=» ocupe dos filas y el 0 dos columnas basta con grid-row: span 2 y grid-column: span 2.

:focus-visible dibuja un contorno muy visible cuando se navega con el teclado, y :active hace que la tecla se hunda un poco al pulsarla.

estilos.css
* { box-sizing: border-box; }

body {
  min-height: 100vh;
  margin: 0;
  display: grid;
  place-items: center;
  background: #e2e8f0;
  font-family: system-ui, sans-serif;
}

.calculadora {
  width: min(320px, 92vw);
  padding: 16px;
  border-radius: 20px;
  background: #0f172a;
  box-shadow: 0 20px 40px rgb(15 23 42 / 0.35);
}

.pantalla {
  display: block;
  margin-bottom: 12px;
  padding: 16px;
  border-radius: 12px;
  background: #1e293b;
  color: #f8fafc;
  font-size: 2.2rem;
  text-align: right;
  overflow-x: auto;
  white-space: nowrap;
}

.teclas {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
}

.tecla {
  padding: 16px 0;
  border: 0;
  border-radius: 12px;
  background: #334155;
  color: #f8fafc;
  font-size: 1.3rem;
  cursor: pointer;
}
.tecla:hover { filter: brightness(1.15); }
.tecla:active { transform: scale(0.96); }
.tecla:focus-visible { outline: 3px solid #fbbf24; outline-offset: 2px; }

.tecla--gris { background: #64748b; }
.tecla--op { background: #f59e0b; color: #0f172a; }
.tecla--igual { grid-row: span 2; background: #22c55e; color: #0f172a; }
.tecla--ancha { grid-column: span 2; }

Paso 3 de 6

Escribir números

En vez de poner un evento en cada botón, se pone uno solo en el contenedor .teclas y se mira qué botón se pulsó con event.target.closest("button"). Es la «delegación de eventos»: menos código y funciona aunque añadas botones después.

El número que se escribe se guarda como texto en actual, para poder añadir cifras al final. Solo se admite una coma, y el 0 inicial se sustituye por la primera cifra.

app.js
const pantalla = document.querySelector("#pantalla");
const teclas = document.querySelector(".teclas");

let actual = "0"; // el número que se está escribiendo, como texto

function mostrar() {
  pantalla.textContent = actual.replace(".", ",");
}

function escribir(caracter) {
  if (caracter === "." && actual.includes(".")) return; // solo una coma
  actual = actual === "0" && caracter !== "." ? caracter : actual + caracter;
  mostrar();
}

teclas.addEventListener("click", (event) => {
  const tecla = event.target.closest("button");
  if (!tecla) return;
  if (tecla.dataset.numero) escribir(tecla.dataset.numero);
});

Paso 4 de 6

Sumar, restar, multiplicar y dividir

Al pulsar una operación se guardan el primer número (anterior) y el operador, y se empieza a escribir el segundo. Al pulsar «=», calcular hace la operación con un if por operador: así nunca se ejecuta texto como código.

Si se encadenan operaciones (2 + 3 + 4), al pulsar el segundo «+» se resuelve antes lo pendiente.

app.jsLo nuevo, resaltado
const pantalla = document.querySelector("#pantalla");
const teclas = document.querySelector(".teclas");

let actual = "0"; // el número que se está escribiendo, como texto
let anterior = null; // el primer número de la operación
let operador = null; // "+", "-", "*" o "/"

function mostrar() {
  pantalla.textContent = actual.replace(".", ",");
}

function escribir(caracter) {
  if (caracter === "." && actual.includes(".")) return; // solo una coma
  actual = actual === "0" && caracter !== "." ? caracter : actual + caracter;
  mostrar();
}

function calcular(a, b, op) {
  if (op === "+") return a + b;
  if (op === "-") return a - b;
  if (op === "*") return a * b;
  return a / b;
}

function elegirOperador(op) {
  if (operador !== null) igual(); // 2 + 3 + … resuelve primero lo pendiente
  anterior = Number(actual);
  operador = op;
  actual = "0";
}

function igual() {
  if (operador === null) return;
  const resultado = calcular(anterior, Number(actual), operador);
  actual = String(resultado);
  anterior = null;
  operador = null;
  mostrar();
}

teclas.addEventListener("click", (event) => {
  const tecla = event.target.closest("button");
  if (!tecla) return;
  if (tecla.dataset.numero) escribir(tecla.dataset.numero);
  if (tecla.dataset.operador) elegirOperador(tecla.dataset.operador);
  if (tecla.dataset.accion === "igual") igual();
});

Paso 5 de 6

Borrar y usar el teclado

C lo borra todo y ← quita la última cifra con slice(0, -1). Después de «=», la variable empezarDeNuevo hace que la siguiente cifra empiece un número nuevo en vez de pegarse al resultado.

Escuchando keydown en el documento, la calculadora funciona también con el teclado: cifras, operaciones, Intro para el resultado, Retroceso y Escape. event.preventDefault() evita que Intro pulse además el botón que tenga el foco.

app.jsLo nuevo, resaltado
const pantalla = document.querySelector("#pantalla");
const teclas = document.querySelector(".teclas");

let actual = "0"; // el número que se está escribiendo, como texto
let anterior = null; // el primer número de la operación
let operador = null; // "+", "-", "*" o "/"
let empezarDeNuevo = false; // tras «=», la siguiente cifra empieza un número nuevo

function mostrar() {
  pantalla.textContent = actual.replace(".", ",");
}

function escribir(caracter) {
  if (empezarDeNuevo) {
    actual = "0";
    empezarDeNuevo = false;
  }
  if (caracter === "." && actual.includes(".")) return; // solo una coma
  actual = actual === "0" && caracter !== "." ? caracter : actual + caracter;
  mostrar();
}

function calcular(a, b, op) {
  if (op === "+") return a + b;
  if (op === "-") return a - b;
  if (op === "*") return a * b;
  return a / b;
}

function elegirOperador(op) {
  if (operador !== null) igual(); // 2 + 3 + … resuelve primero lo pendiente
  anterior = Number(actual);
  operador = op;
  actual = "0";
  empezarDeNuevo = false;
}

function igual() {
  if (operador === null) return;
  const resultado = calcular(anterior, Number(actual), operador);
  actual = String(resultado);
  anterior = null;
  operador = null;
  empezarDeNuevo = true;
  mostrar();
}

function borrarTodo() {
  actual = "0";
  anterior = null;
  operador = null;
  mostrar();
}

function borrarCifra() {
  actual = actual.length > 1 ? actual.slice(0, -1) : "0";
  mostrar();
}

teclas.addEventListener("click", (event) => {
  const tecla = event.target.closest("button");
  if (!tecla) return;
  if (tecla.dataset.numero) escribir(tecla.dataset.numero);
  if (tecla.dataset.operador) elegirOperador(tecla.dataset.operador);
  if (tecla.dataset.accion === "igual") igual();
  if (tecla.dataset.accion === "borrar") borrarTodo();
  if (tecla.dataset.accion === "atras") borrarCifra();
});

document.addEventListener("keydown", (event) => {
  if (/^[0-9]$/.test(event.key)) escribir(event.key);
  else if (event.key === "." || event.key === ",") escribir(".");
  else if (["+", "-", "*", "/"].includes(event.key)) elegirOperador(event.key);
  else if (event.key === "Enter" || event.key === "=") {
    event.preventDefault();
    igual();
  } else if (event.key === "Backspace") borrarCifra();
  else if (event.key === "Escape") borrarTodo();
});

Paso 6 de 6

Errores y decimales

Dividir entre 0 en JavaScript da Infinity, que no es un resultado útil: la calculadora lo detecta con Number.isFinite y muestra un aviso.

Los ordenadores guardan los decimales en binario, y algunos no se pueden representar exactos: 0,1 + 0,2 da 0,30000000000000004. Redondear el resultado a 10 cifras significativas con toPrecision(10) lo deja en 0,3.

app.jsLo nuevo, resaltado
const pantalla = document.querySelector("#pantalla");
const teclas = document.querySelector(".teclas");

let actual = "0"; // el número que se está escribiendo, como texto
let anterior = null; // el primer número de la operación
let operador = null; // "+", "-", "*" o "/"
let empezarDeNuevo = false; // tras «=», la siguiente cifra empieza un número nuevo

function mostrar() {
  pantalla.textContent = actual.replace(".", ",");
}

function escribir(caracter) {
  if (empezarDeNuevo) {
    actual = "0";
    empezarDeNuevo = false;
  }
  if (caracter === "." && actual.includes(".")) return; // solo una coma
  actual = actual === "0" && caracter !== "." ? caracter : actual + caracter;
  mostrar();
}

function calcular(a, b, op) {
  if (op === "/" && b === 0) return NaN; // no se puede dividir entre 0
  if (op === "+") return a + b;
  if (op === "-") return a - b;
  if (op === "*") return a * b;
  return a / b;
}

function elegirOperador(op) {
  if (operador !== null) igual(); // 2 + 3 + … resuelve primero lo pendiente
  anterior = Number(actual);
  operador = op;
  actual = "0";
  empezarDeNuevo = false;
}

function igual() {
  if (operador === null) return;
  const resultado = calcular(anterior, Number(actual), operador);
  anterior = null;
  operador = null;
  empezarDeNuevo = true;
  if (!Number.isFinite(resultado)) {
    actual = "0";
    pantalla.textContent = "No se puede dividir entre 0";
    return;
  }
  // Redondear a 10 cifras evita resultados como 0,30000000000000004 al sumar 0,1 + 0,2
  actual = String(Number(resultado.toPrecision(10)));
  mostrar();
}

function borrarTodo() {
  actual = "0";
  anterior = null;
  operador = null;
  mostrar();
}

function borrarCifra() {
  actual = actual.length > 1 ? actual.slice(0, -1) : "0";
  mostrar();
}

teclas.addEventListener("click", (event) => {
  const tecla = event.target.closest("button");
  if (!tecla) return;
  if (tecla.dataset.numero) escribir(tecla.dataset.numero);
  if (tecla.dataset.operador) elegirOperador(tecla.dataset.operador);
  if (tecla.dataset.accion === "igual") igual();
  if (tecla.dataset.accion === "borrar") borrarTodo();
  if (tecla.dataset.accion === "atras") borrarCifra();
});

document.addEventListener("keydown", (event) => {
  if (/^[0-9]$/.test(event.key)) escribir(event.key);
  else if (event.key === "." || event.key === ",") escribir(".");
  else if (["+", "-", "*", "/"].includes(event.key)) elegirOperador(event.key);
  else if (event.key === "Enter" || event.key === "=") {
    event.preventDefault();
    igual();
  } else if (event.key === "Backspace") borrarCifra();
  else if (event.key === "Escape") borrarTodo();
});

Retos para seguir

Has terminado el proyecto. Si quieres ir más allá, prueba a añadirle esto por tu cuenta:

  1. Añade una tecla de porcentaje (%) que divida el número actual entre 100.
  2. Muestra la operación pendiente encima del resultado, por ejemplo «12 ×».
  3. Guarda las últimas 5 operaciones en un historial que se pueda ver debajo de la calculadora.

Conceptos clave

Repasa la ruta de JavaScript o elige otro proyecto.