Saltar al contenido
canvas.js · devschool

Canvas en JavaScript: dibujar y animar en el navegador

Lección 36 de 43 · 14 min de lectura · Actualizado el

En esta lección
  1. El lienzo y el contexto
  2. Coordenadas
  3. Rectángulos
  4. Caminos: líneas y formas libres
  5. Arcos y círculos
  6. Texto
  7. Imágenes
  8. Transformaciones
  9. Animación con requestAnimationFrame
  10. Nitidez en pantallas de alta densidad
  11. Accesibilidad
  12. Errores frecuentes
  13. Resumen

El elemento <canvas> es un rectángulo de píxeles en el que dibujas con JavaScript. Con él se hacen gráficos de datos, juegos, editores de imagen, firmas digitales o efectos visuales. En la lección de SVG y canvas viste qué es y cuándo elegirlo frente a SVG; aquí vas a aprender a dibujar de verdad: rectángulos, caminos, arcos, texto, imágenes y transformaciones. Terminarás con una animación de una pelota que rebota y verás cómo hacer que se vea nítida en móviles y sea accesible.

Conviene que domines el DOM, los eventos y la clase Math.

El lienzo y el contexto

En el HTML solo se declara el lienzo con su tamaño:

<canvas id="lienzo" width="400" height="250">
  Gráfico de ventas por mes: enero 120, febrero 90, marzo 150.
</canvas>

El texto de dentro solo se ve si el navegador no puede mostrar el canvas, y lo leen los lectores de pantalla. Úsalo para describir lo que se dibuja.

Para dibujar necesitas el contexto, el objeto que tiene todos los métodos de dibujo:

const lienzo = document.querySelector("#lienzo");
const ctx = lienzo.getContext("2d");

"2d" pide el contexto de dos dimensiones, el que se usa casi siempre. Existe también "webgl" para gráficos 3D, pero eso queda para bibliotecas como Three.js. Por costumbre, la variable se llama ctx.

Tamaño del lienzo frente a tamaño en pantalla

Un canvas tiene dos tamaños:

  • Los atributos width y height: cuántos píxeles tiene por dentro el dibujo (si no los pones, 300 × 150).
  • El tamaño CSS: cuánto ocupa en la página.

Si solo cambias el tamaño con CSS, el navegador estira los píxeles y todo se ve borroso o deformado. Regla práctica: fija el tamaño con los atributos (o con lienzo.width = ... en JavaScript) y, como mucho, pon max-width: 100% en el CSS para que no se salga en móviles. Ojo: cambiar width o height borra todo el dibujo y reinicia el contexto.

Coordenadas

El punto (0, 0) está en la esquina superior izquierda. La x crece hacia la derecha y la y crece hacia abajo, al revés que en las gráficas de matemáticas.

(0,0) ──────────► x
  │
  │      ● (100, 50)
  │
  ▼
  y

En un lienzo de 400 × 250, la esquina inferior derecha es (400, 250). Si dibujas un gráfico de barras, tendrás que restar: una barra de altura h que arranca en el suelo empieza en y = 250 - h.

Rectángulos

Los rectángulos son la única figura con métodos propios:

ctx.fillStyle = "#2563eb";
ctx.fillRect(20, 20, 120, 80);    // relleno: x, y, ancho, alto

ctx.strokeStyle = "#dc2626";
ctx.lineWidth = 4;
ctx.strokeRect(160, 20, 120, 80); // solo el borde

ctx.clearRect(40, 40, 30, 30);    // borra (deja transparente) una zona

fillStyle y strokeStyle aceptan cualquier color de CSS: "tomato", "#2563eb", "rgb(37 99 235 / 50%)", "hsl(220 80% 50%)". Funcionan como un pincel activo: el color que pongas se queda para todo lo que dibujes después, hasta que lo cambies.

Un gráfico de barras

Con un bucle y fillRect ya puedes pintar datos:

const ventas = [120, 90, 150, 60];
const anchoBarra = 60;

ventas.forEach((valor, i) => {
  const x = 30 + i * (anchoBarra + 20);
  const y = 250 - valor;
  ctx.fillStyle = valor >= 100 ? "#16a34a" : "#f59e0b";
  ctx.fillRect(x, y, anchoBarra, valor);
});

Caminos: líneas y formas libres

Para cualquier otra forma se usa un camino (path): una lista de puntos que luego rellenas o repasas. Los pasos son siempre los mismos:

  1. beginPath(): empieza un camino nuevo.
  2. moveTo(x, y): mueve el lápiz sin pintar.
  3. lineTo(x, y): traza una línea hasta ese punto.
  4. closePath() (opcional): vuelve al punto inicial.
  5. stroke() para el borde, fill() para el relleno, o los dos.
// Un triángulo (una montaña)
ctx.beginPath();
ctx.moveTo(50, 200);
ctx.lineTo(150, 60);
ctx.lineTo(250, 200);
ctx.closePath();
ctx.fillStyle = "#64748b";
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "#1e293b";
ctx.stroke();
// Una línea quebrada (el perfil de una ruta)
const alturas = [30, 80, 60, 120, 90];
ctx.beginPath();
alturas.forEach((h, i) => {
  const x = 20 + i * 80;
  const y = 240 - h;
  if (i === 0) ctx.moveTo(x, y);
  else ctx.lineTo(x, y);
});
ctx.lineJoin = "round"; // esquinas redondeadas
ctx.stroke();

Cuidado: si olvidas beginPath(), el nuevo dibujo se añade al camino anterior. Al llamar a fill() o stroke() se vuelve a pintar todo, incluidas las figuras de antes, a veces con el color nuevo.

Otras herramientas de los caminos: quadraticCurveTo y bezierCurveTo para curvas, rect para añadir un rectángulo al camino y roundRect(x, y, ancho, alto, radio) para rectángulos con esquinas redondeadas.

Arcos y círculos

arc(x, y, radio, inicio, fin) añade al camino un arco con centro en (x, y). Los ángulos van en radianes: una vuelta completa son Math.PI * 2 (unos 6,28), media vuelta Math.PI. El ángulo 0 está a la derecha (las 3 en un reloj) y se avanza en el sentido de las agujas del reloj.

// Círculo completo
ctx.beginPath();
ctx.arc(100, 100, 40, 0, Math.PI * 2);
ctx.fillStyle = "#facc15";
ctx.fill();

// Semicírculo (la mitad de abajo)
ctx.beginPath();
ctx.arc(250, 100, 40, 0, Math.PI);
ctx.stroke();

Para convertir grados a radianes: grados * Math.PI / 180.

Un gráfico de tarta

Cada porción es un arco que empieza donde terminó la anterior, unido al centro:

const votos = { Montaña: 45, Playa: 30, Ciudad: 25 };
const colores = ["#16a34a", "#0ea5e9", "#f97316"];
const total = Object.values(votos).reduce((a, b) => a + b, 0);
let angulo = -Math.PI / 2; // empezar arriba, a las 12

Object.values(votos).forEach((v, i) => {
  const porcion = (v / total) * Math.PI * 2;
  ctx.beginPath();
  ctx.moveTo(200, 125);
  ctx.arc(200, 125, 100, angulo, angulo + porcion);
  ctx.closePath();
  ctx.fillStyle = colores[i];
  ctx.fill();
  angulo += porcion;
});

Texto

ctx.font = "bold 24px system-ui, sans-serif"; // igual que la propiedad font de CSS
ctx.fillStyle = "#0f172a";
ctx.textAlign = "center";       // respecto a la x: left, center, right
ctx.textBaseline = "middle";    // respecto a la y: top, middle, alphabetic...
ctx.fillText("Ruta del Cares", 200, 40);

ctx.strokeText("Contorno", 200, 80); // solo el contorno de las letras

const ancho = ctx.measureText("Ruta del Cares").width; // cuánto ocupa, en píxeles

Por defecto la y marca la línea base del texto (donde se apoyan las letras), así que con y = 0 el texto quedaría fuera por arriba. Con textBaseline = "middle" la y pasa a ser el centro, que es más intuitivo para centrar.

Imágenes

drawImage pinta una imagen en el lienzo. La imagen tiene que haber terminado de cargarse; si no, no se dibuja nada:

const foto = new Image();
foto.src = "picos.jpg";
foto.addEventListener("load", () => {
  ctx.drawImage(foto, 0, 0);            // a tamaño natural
  ctx.drawImage(foto, 0, 0, 200, 120);  // escalada a 200 × 120
});

Hay una tercera forma con nueve argumentos para recortar: primero el trozo de la imagen original (x, y, ancho, alto) y después dónde y a qué tamaño pintarlo en el lienzo. Es lo que se usa en los juegos para sacar cada personaje de una sola imagen con todos los dibujos (sprite sheet).

drawImage también acepta un <video> (pinta el fotograma actual) u otro canvas.

Exportar el dibujo

const enlace = document.createElement("a");
enlace.download = "mi-dibujo.png";
enlace.href = lienzo.toDataURL("image/png");
enlace.click(); // descarga el lienzo como imagen

Nota: si dibujas una imagen de otro dominio que no da permiso (CORS), el canvas queda “contaminado” y toDataURL lanza un error. Es una medida de seguridad para que no puedas leer imágenes privadas de otras webs.

Transformaciones

En vez de calcular las coordenadas de cada punto girado o desplazado, puedes mover el sistema de coordenadas:

  • translate(x, y): mueve el origen (0, 0) a otro punto.
  • rotate(angulo): gira los ejes alrededor del origen (en radianes).
  • scale(sx, sy): agranda o encoge todo lo que se dibuje después.

Las transformaciones se acumulan y afectan a todo lo que dibujes después. Por eso se rodean de save() y restore(): save guarda el estado actual (transformaciones, colores, grosor de línea…) y restore lo recupera.

// Las agujas de un reloj que marcan las 3:00
function aguja(angulo, largo, grosor) {
  ctx.save();
  ctx.translate(200, 125);   // el origen pasa al centro del reloj
  ctx.rotate(angulo);        // giramos los ejes
  ctx.lineWidth = grosor;
  ctx.lineCap = "round";
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(0, -largo);     // siempre "hacia arriba"; el giro hace el resto
  ctx.stroke();
  ctx.restore();             // todo vuelve a como estaba
}

aguja(0, 80, 4);             // minutero en las 12
aguja(Math.PI / 2, 50, 8);   // horario en las 3 (un cuarto de vuelta)

Fíjate en que la aguja siempre se dibuja igual, de (0, 0) hacia arriba. Lo único que cambia es el giro. Para girar algo sobre su propio centro, primero se hace translate a ese centro, luego rotate, y se dibuja alrededor de (0, 0).

Animación con requestAnimationFrame

Canvas no recuerda lo que has dibujado: solo quedan píxeles. Para mover algo hay que repetir muy rápido tres pasos: borrar todo, actualizar las posiciones y volver a dibujar. Cada repetición es un fotograma.

Para repetir se usa requestAnimationFrame(funcion), que llama a tu función justo antes del siguiente refresco de la pantalla (normalmente 60 veces por segundo) y se pausa sola cuando la pestaña no se ve. Ya lo presentaste en temporizadores; aquí lo usarás de verdad.

La pelota que rebota

const ANCHO = lienzo.width;
const ALTO = lienzo.height;
const pelota = { x: 50, y: 50, vx: 150, vy: 100, r: 15 }; // velocidad en píxeles por segundo

let anterior = performance.now();

function fotograma(ahora) {
  const seg = (ahora - anterior) / 1000; // segundos desde el fotograma anterior
  anterior = ahora;

  // 1. Actualizar
  pelota.x += pelota.vx * seg;
  pelota.y += pelota.vy * seg;
  if (pelota.x - pelota.r < 0 || pelota.x + pelota.r > ANCHO) pelota.vx = -pelota.vx;
  if (pelota.y - pelota.r < 0 || pelota.y + pelota.r > ALTO) pelota.vy = -pelota.vy;

  // 2. Borrar y 3. dibujar
  ctx.clearRect(0, 0, ANCHO, ALTO);
  ctx.beginPath();
  ctx.arc(pelota.x, pelota.y, pelota.r, 0, Math.PI * 2);
  ctx.fillStyle = "#dc2626";
  ctx.fill();

  requestAnimationFrame(fotograma); // pedir el siguiente
}

requestAnimationFrame(fotograma);

La clave está en el rebote: cuando el borde de la pelota toca una pared, se invierte la velocidad en ese eje.

¿Por qué multiplicar la velocidad por seg en vez de sumar un número fijo? Porque no todas las pantallas refrescan igual: hay monitores de 60, 120 o 144 Hz. Si sumaras 2 píxeles por fotograma, la pelota iría el doble de rápido en una pantalla de 120 Hz. Usando el tiempo real transcurrido, va a la misma velocidad en todas. requestAnimationFrame pasa a tu función el momento actual en milisegundos (ahora), y performance.now() da ese mismo reloj.

Para parar la animación, guarda el número que devuelve requestAnimationFrame y pásaselo a cancelAnimationFrame(id). El ejemplo de “Pruébalo” lo hace con un botón de pausa.

Clics en el lienzo

Las coordenadas del ratón (e.clientX, e.clientY) son de la ventana, no del lienzo. Para pasarlas al lienzo, resta la posición del canvas:

lienzo.addEventListener("click", (e) => {
  const caja = lienzo.getBoundingClientRect();
  const x = e.clientX - caja.left;
  const y = e.clientY - caja.top;
  console.log(`Clic en (${Math.round(x)}, ${Math.round(y)})`);
});

Si el canvas está escalado con CSS (por ejemplo, con max-width: 100% en un móvil), multiplica además por lienzo.width / caja.width para obtener el píxel del dibujo.

Nitidez en pantallas de alta densidad

En móviles y portátiles modernos, cada “píxel CSS” son en realidad 2 o 3 píxeles físicos. window.devicePixelRatio dice cuántos. Un canvas de 300 píxeles mostrado a 300 píxeles CSS en una pantalla con devicePixelRatio = 2 se estira al doble y se ve borroso, sobre todo el texto.

La solución: dar al lienzo más píxeles internos y escalar el contexto para seguir dibujando con las medidas de siempre:

const ancho = 300;
const alto = 150;
const dpr = window.devicePixelRatio || 1;

lienzo.width = ancho * dpr;        // píxeles reales
lienzo.height = alto * dpr;
lienzo.style.width = ancho + "px"; // tamaño en la página
lienzo.style.height = alto + "px";
ctx.scale(dpr, dpr);               // seguir dibujando en coordenadas de 300 × 150

A partir de ahí, ctx.fillRect(0, 0, 300, 150) llena todo el lienzo, pero con el doble o el triple de detalle.

Accesibilidad

Para un lector de pantalla, un canvas es una caja vacía: no sabe qué hay dibujado. Ten en cuenta:

  • Describe el contenido: con texto dentro del <canvas>, o con role="img" y aria-label="..." en la etiqueta. Si muestra datos, ofrece también una tabla con los mismos datos.
  • No metas en un canvas textos, botones ni formularios que la gente tenga que leer o usar. Ponlos en HTML normal, encima o al lado.
  • Respeta a quien prefiere menos movimiento: algunas personas se marean con animaciones. Compruébalo antes de arrancar una animación que no sea imprescindible:
const sinMovimiento = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!sinMovimiento) {
  requestAnimationFrame(fotograma);
}
  • Si la animación dura más de unos segundos, ofrece un botón para pausarla.

Errores frecuentes

  • Cambiar el tamaño solo con CSS: el dibujo se estira y se ve borroso. Usa los atributos width y height.
  • Olvidar beginPath(): las figuras nuevas se suman a las anteriores y se repintan.
  • Usar grados en arc o rotate: van en radianes. Math.PI * 2 es una vuelta.
  • Dibujar una imagen antes de que cargue: espera al evento load.
  • No borrar en cada fotograma: la pelota deja un rastro. Llama a clearRect antes de dibujar.
  • Animar con setInterval: va peor y no se pausa con la pestaña oculta. Usa requestAnimationFrame.
  • Mover cantidades fijas por fotograma: la velocidad depende de la pantalla. Multiplica por el tiempo transcurrido.
  • No usar save/restore con transformaciones: los giros y desplazamientos se acumulan y todo acaba torcido.

Resumen

TareaCódigo
Obtener el contextolienzo.getContext("2d")
RectángulosfillRect, strokeRect, clearRect
Colores y líneasfillStyle, strokeStyle, lineWidth
CaminosbeginPath, moveTo, lineTo, closePath, fill, stroke
Círculosarc(x, y, r, 0, Math.PI * 2)
Textofont, textAlign, textBaseline, fillText
ImágenesdrawImage tras el evento load
Transformarsave, translate, rotate, scale, restore
AnimarrequestAnimationFrame con borrar, actualizar y dibujar
NitidezMultiplicar el tamaño por devicePixelRatio y ctx.scale
  • Canvas no recuerda lo dibujado: para mover algo, se borra y se pinta de nuevo.
  • Describe siempre el contenido del canvas y respeta prefers-reduced-motion.

En la siguiente lección entenderás cómo JavaScript hace varias cosas “a la vez” sin bloquearse: asincronía.

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

canvas.html
Resultado

Pon a prueba lo que has aprendido

[JavaScript] Cambias el tamaño de un canvas solo con CSS (width: 600px) y el dibujo se ve borroso. ¿Por qué?

[JavaScript] Dibujas dos círculos con arc() y fill() sin llamar a beginPath() entre ellos. ¿Qué pasa?

[JavaScript] ¿En qué unidad recibe los ángulos ctx.arc()?

[JavaScript] En una animación con requestAnimationFrame, la pelota deja un rastro. ¿Qué falta en cada fotograma?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.