devicePixelRatio: densidad, zoom y sus cambios
Entender qué mide exactamente devicePixelRatio, por qué cambia sin que el usuario cambie de pantalla, y cómo enterarte de esos cambios en el momento en que ocurren.
Qué mide exactamente
window.devicePixelRatio es el cociente entre el tamaño de un píxel CSS y el tamaño de un píxel del dispositivo:
devicePixelRatio = pixeles fisicos / pixeles CSSUn valor de 2 significa que cada píxel CSS se dibuja con una rejilla de 2 por 2 píxeles físicos, es decir, cuatro veces más muestras por unidad de área. Un valor de 1 es la correspondencia uno a uno de los monitores clásicos.
Los valores que se encuentran en la práctica son variados y no siempre enteros. Los portátiles de gama alta suelen dar 2. Muchos Android dan 2,625, 2,75 o 3. Un monitor 4K de 27 pulgadas con escala del sistema al 150 % da 1,5. Y el zoom del navegador multiplica todo lo anterior: un zoom al 110 % sobre una pantalla de ratio 2 da 2,2.
Esa última observación es la que cambia el modelo mental: devicePixelRatio no describe el hardware, describe la relación actual entre el sistema de coordenadas CSS y los píxeles reales, incluyendo todos los factores que la alteran.
Qué lo hace cambiar
Hay al menos cinco causas, y cuatro de ellas ocurren durante la vida de la página sin recargarla.
El zoom del navegador. Es la más frecuente con diferencia. Cada pulsación de acercar o alejar cambia el valor.
Mover la ventana a otro monitor. En un escritorio con un portátil de alta densidad y un monitor externo estándar, arrastrar la ventana de uno a otro cambia el ratio a mitad de sesión.
Cambiar la escala del sistema operativo. Menos frecuente pero posible sin cerrar el navegador.
El zoom por gestos en móvil, en algunos navegadores y bajo ciertas configuraciones del viewport.
La rotación del dispositivo, en casos concretos donde la densidad efectiva cambia.
Un canvas que lee el valor una sola vez sobrevive a ninguna de las cinco.
Detectar el cambio
No existe un evento devicepixelratiochange. La técnica canónica usa matchMedia con una consulta de resolución construida a partir del valor actual, que dejará de coincidir en cuanto el valor cambie.
function alCambiarDensidad(callback) { let mql; function suscribir() { const dpr = window.devicePixelRatio; // Consulta que solo es cierta mientras el ratio siga siendo exactamente este mql = matchMedia(`(resolution: ${dpr}dppx)`); mql.addEventListener('change', alCambiar, { once: true }); } function alCambiar() { callback(window.devicePixelRatio); suscribir(); // hay que reconstruir la consulta con el valor nuevo } suscribir(); return () => mql.removeEventListener('change', alCambiar);}alCambiarDensidad(dpr => { console.log('Nueva densidad:', dpr); redimensionar();});La parte que se olvida siempre es la resuscripción. La consulta (resolution: 2dppx) deja de coincidir cuando el ratio pasa a 2,2 y dispara el evento, pero a partir de ese momento esa consulta ya no sirve para detectar nada: hay que construir una nueva con el valor actual. Por eso el listener es once y se vuelve a suscribir dentro del manejador.
La unidad dppx significa “puntos por píxel CSS” y es exactamente la unidad de devicePixelRatio. Usar dpi con conversiones a mano es innecesario y propenso a errores de redondeo.
Hay una alternativa, más robusta en la práctica, que es no detectar la densidad por separado sino observar el tamaño real del elemento en píxeles del dispositivo, porque cualquier cambio de densidad cambia también esa medida. Es lo que hace ResizeObserver con la caja de píxeles del dispositivo, y tiene su propia lección.
Cuándo importa cada decimal
No todos los contenidos sufren igual una densidad mal resuelta, y saberlo permite priorizar.
El texto es el más sensible. El rasterizador de fuentes usa toda la resolución disponible y el ojo detecta la diferencia inmediatamente. Un canvas con etiquetas a ratio 1 en una pantalla de ratio 2 se ve claramente peor que la interfaz que lo rodea, lo que resulta especialmente evidente porque está al lado de texto del DOM que sí está nítido.
Las líneas finas son las segundas. Una línea de un píxel en un búfer a la mitad de resolución se convierte en dos píxeles borrosos. Las rejillas, los ejes y los bordes son donde primero se nota.
Las formas rellenas grandes casi no sufren. Un rectángulo de color plano se ve igual a cualquier ratio salvo en sus bordes, y si están alineados a la rejilla ni siquiera ahí.
Las imágenes fotográficas dependen de la fuente. Si la imagen original ya tenía menos resolución que el búfer, subir el ratio no aporta nada.
De ahí sale un criterio útil: la pregunta no es “¿qué ratio uso?” sino “¿qué hay en este canvas?”. Un mapa de calor puede permitirse ratio 1; un gráfico con ejes etiquetados, no.
Una utilidad completa
Juntando todo, esta pequeña utilidad centraliza la lectura de la densidad y el aviso de sus cambios, y es la pieza que las dos lecciones siguientes van a usar.
export function crearDensidad({ maximo = Infinity } = {}) { const oyentes = new Set(); let mql = null; const valor = () => Math.min(window.devicePixelRatio || 1, maximo); function suscribir() { mql = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); mql.addEventListener('change', notificar, { once: true }); } function notificar() { for (const f of oyentes) f(valor()); suscribir(); } suscribir(); return { get valor() { return valor(); }, alCambiar(f) { oyentes.add(f); return () => oyentes.delete(f); }, };}Fíjate en un detalle: la consulta de matchMedia usa window.devicePixelRatio sin limitar, porque queremos enterarnos de cualquier cambio real de densidad aunque nuestro valor efectivo esté limitado y no vaya a cambiar. Confundir esos dos valores hace que el aviso no llegue nunca cuando el usuario pasa de ratio 3 a ratio 4.