Dibujar con canvas en JavaScript: formas, texto y animación

El elemento <canvas> es un lienzo de píxeles sobre el que JavaScript dibuja. Con él se hacen gráficas, juegos, editores de imágenes y simulaciones. Esta página cubre lo básico para empezar a dibujar.

Formas, líneas y texto

Para probarlo, guarda el código en un archivo .html, ábrelo en el navegador y abre la consola con F12 (en Mac, Cmd+Opción+J). Todos los ejemplos de esta página son un único archivo, sin nada más que instalar.

<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Canvas</title></head>
<body>
  <canvas id="lienzo" width="400" height="250"></canvas>
  <script type="module">
    const lienzo = document.querySelector("#lienzo");
    const ctx = lienzo.getContext("2d");

    // Fondo
    ctx.fillStyle = "#f5f8fb";
    ctx.fillRect(0, 0, lienzo.width, lienzo.height);

    // Un rectángulo relleno y otro solo con borde
    ctx.fillStyle = "#1e64af";
    ctx.fillRect(20, 20, 120, 80);
    ctx.strokeStyle = "#00264f";
    ctx.lineWidth = 4;
    ctx.strokeRect(160, 20, 120, 80);

    // Un círculo: un arco de 0 a 2π
    ctx.beginPath();
    ctx.arc(340, 60, 40, 0, Math.PI * 2);
    ctx.fillStyle = "#33475b";
    ctx.fill();

    // Una línea quebrada
    ctx.beginPath();
    ctx.moveTo(20, 200);
    ctx.lineTo(120, 140);
    ctx.lineTo(220, 200);
    ctx.lineTo(320, 140);
    ctx.strokeStyle = "#1e64af";
    ctx.lineWidth = 3;
    ctx.stroke();

    // Texto
    ctx.fillStyle = "#0f1f33";
    ctx.font = "bold 20px sans-serif";
    ctx.fillText("Hola, canvas", 20, 240);

    // Leer un píxel: rojo, verde, azul y opacidad
    console.log("píxel (50, 50):", ctx.getImageData(50, 50, 1, 1).data.join(", "));
    console.log("píxel (340, 60):", ctx.getImageData(340, 60, 1, 1).data.join(", "));
    console.log("píxel (390, 240):", ctx.getImageData(390, 240, 1, 1).data.join(", "));
  </script>
</body>
</html>
consolapíxel (50, 50): 30, 100, 175, 255
píxel (340, 60): 51, 71, 91, 255
píxel (390, 240): 245, 248, 251, 255

El lienzo de 400 por 250 píxeles con sus figuras y las coordenadas de cada unaLas coordenadas del canvas: el origen está arriba a la izquierdafillRect(20, 20,120, 80)strokeRect(160,20, 120, 80)arc(340, 60, 40, …)moveTo y lineTo(0, 0)x crecey crece(400, 250)

El dibujo que produce el código, con las coordenadas de cada figura. El eje y crece hacia abajo. Esquema: decodigo.com.

Cómo funciona

Todo se hace sobre el contexto. getContext("2d") devuelve el objeto con los métodos de dibujo. El lienzo en sí solo guarda los píxeles.

Las coordenadas empiezan arriba a la izquierda, y la y crece hacia abajo, al revés que en matemáticas. Es la causa número uno de dibujos boca abajo.

Primero se elige el estilo, luego se dibuja. fillStyle, strokeStyle, lineWidth y font afectan a todo lo que se dibuje a continuación, hasta que los cambies.

Rectángulos directos, todo lo demás con caminos. fillRect y strokeRect dibujan al momento. Para círculos, líneas o polígonos se abre un camino con beginPath(), se traza con moveTo, lineTo o arc, y se pinta con fill() o stroke(). Los ángulos de arc van en radianes: una vuelta entera es Math.PI * 2.

Y getImageData lee los píxeles: cuatro números del 0 al 255 por píxel, rojo, verde, azul y opacidad. En la salida se ve el azul del rectángulo, el gris del círculo y el fondo. Chromium avisa de que, si vas a leer muchas veces, conviene crear el contexto con getContext("2d", { willReadFrequently: true }):

aviso en la consola de ChromiumCanvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently

El error frecuente: un canvas borroso

Si el tamaño se fija con CSS en lugar de con los atributos width y height, el canvas se estira y se ve borroso. Y aunque lo hagas bien, en una pantalla de alta densidad, como la de un móvil o un Mac, cada píxel CSS son dos o tres píxeles físicos. La solución es dar al lienzo más píxeles reales y escalar el dibujo:

<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Canvas nítido</title></head>
<body>
  <canvas id="lienzo" style="width: 400px; height: 250px"></canvas>
  <script type="module">
    const lienzo = document.querySelector("#lienzo");
    const ctx = lienzo.getContext("2d");
    const escala = window.devicePixelRatio || 1;

    lienzo.width = 400 * escala;
    lienzo.height = 250 * escala;
    ctx.scale(escala, escala);

    console.log("devicePixelRatio:", escala, "| píxeles reales:", lienzo.width, "x", lienzo.height);
    ctx.fillStyle = "#1e64af";
    ctx.fillRect(20, 20, 120, 80);
  </script>
</body>
</html>
consola, en una pantalla doble y en una normaldevicePixelRatio: 2 | píxeles reales: 800 x 500
devicePixelRatio: 1 | píxeles reales: 400 x 250

En la pantalla doble, el lienzo mide 400 x 250 en la página pero tiene 800 x 500 píxeles reales. Gracias a ctx.scale, el resto del código sigue dibujando con las coordenadas de siempre.

Animar: requestAnimationFrame

<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Animación</title></head>
<body>
  <canvas id="lienzo" width="400" height="120"></canvas>
  <script type="module">
    const lienzo = document.querySelector("#lienzo");
    const ctx = lienzo.getContext("2d");
    let x = 30;
    let velocidad = 120; // píxeles por segundo
    let anterior = performance.now();
    let cuadros = 0;

    function cuadro(ahora) {
      const dt = (ahora - anterior) / 1000;
      anterior = ahora;
      x += velocidad * dt;
      if (x > lienzo.width - 30 || x < 30) velocidad = -velocidad;

      ctx.clearRect(0, 0, lienzo.width, lienzo.height);
      ctx.beginPath();
      ctx.arc(x, 60, 20, 0, Math.PI * 2);
      ctx.fillStyle = "#1e64af";
      ctx.fill();

      cuadros++;
      requestAnimationFrame(cuadro);
    }
    requestAnimationFrame(cuadro);

    setTimeout(() => console.log(`en 1 s: ${cuadros} cuadros, la bola va por x = ${Math.round(x)}`), 1000);
  </script>
</body>
</html>
consolaen 1 s: 60 cuadros, la bola va por x = 149

requestAnimationFrame pide al navegador que ejecute la función antes de pintar el siguiente cuadro, normalmente 60 veces por segundo, que es lo que medimos. Cada cuadro se borra el lienzo y se vuelve a dibujar todo. La clave es mover las cosas según el tiempo transcurrido (dt) y no un número fijo de píxeles por cuadro: así la bola va igual de rápido en una pantalla de 60 Hz que en una de 120. Además, el navegador pausa la animación cuando la pestaña no se ve, cosa que setInterval no hace.

¿Canvas o SVG? SVG crea un elemento por cada figura, que se puede seleccionar, animar con CSS y escalar sin perder nitidez: es mejor para esquemas y gráficas con pocos elementos. Canvas solo guarda píxeles, y por eso aguanta miles de figuras moviéndose: es mejor para juegos y simulaciones, como la de las bandadas.

Cada página de esta sección se abrió en Chromium 131, el motor de Chrome, controlado con Playwright, antes de publicarla; lo que aparece como consola es lo que imprimió de verdad. Documentación oficial: el tutorial de Canvas en MDN.