Permisos declarativos con Capability Elements

El modelo de permisos de la web tiene un problema de timing. navigator.geolocation.getCurrentPosition() o getUserMedia() son llamadas imperativas: el prompt aparece cuando tu código lo pide, que muchas veces es al cargar la página, antes de que el usuario tenga la menor idea de para qué querés su cámara.
El resultado lo conocemos todos: el usuario bloquea por reflejo. Y ahí empieza el verdadero problema, que no es el bloqueo sino la recuperación. Para desbloquear tiene que ir al candadito de la barra de direcciones, o peor, a settings del navegador, encontrar tu sitio y cambiar el permiso. Nadie hace eso. La feature queda muerta para ese usuario, para siempre.
Encima, los navegadores empezaron a intervenir. Chrome aplica un quiet block si el usuario descartó el prompt tres veces: tu llamada a la API falla en silencio, sin prompt, sin error visible para el usuario. Es temporal —arranca en una semana—, pero mientras dura no tenés forma de avisarle al usuario que hay un permiso de por medio. Tu código "funciona", el usuario ve una feature rota, y no hay ninguna señal en la UI de que hay un permiso de por medio.
La respuesta de Chrome a esto es un conjunto de elementos HTML nuevos: los Capability Elements. Con Chrome 153 (8 de septiembre de 2026) ya son cuatro.
De <permission> genérico a elementos específicos
La idea empezó como PEPC (Page-Embedded Permission Control) con un elemento genérico:
<permission type="camera microphone">
Ese elemento tuvo un origin trial largo, de Chrome 126 a 143, y los resultados fueron lo suficientemente buenos como para justificar seguir. Pero el <permission> genérico no le gustó a nadie más: Mozilla lo marcó negativo y WebKit directamente se opuso, con objeciones que iban de la complejidad a la i18n. El punto de fondo es razonable: un elemento "talle único" con un atributo type esconde que cada capacidad tiene comportamientos, estados y flujos de recuperación completamente distintos. La geolocalización devuelve una posición; la cámara devuelve un stream que hay que abrir, cerrar y elegir dispositivo.
Así que la propuesta se partió en elementos específicos:
| Elemento | Desde | Qué hace |
|---|---|---|
<geolocation> | Chrome 144 | Ubicación |
<usermedia> | Chrome 151 | Cámara y micrófono |
<camera> | Chrome 153 | Solo video |
<microphone> | Chrome 153 | Solo audio |
Ya no median el permiso: entregan el dato
El <permission> original gestionaba estado: permitido, denegado, pendiente. Después de que el usuario aceptaba, vos igual tenías que llamar a la API de JavaScript para obtener el dato.
Los Capability Elements hacen las dos cosas. El elemento captura la intención del usuario, gestiona el prompt del navegador y te entrega el objeto: un GeolocationPosition en un caso, un MediaStream en el otro. No hay un getUserMedia() después.
<geolocation onlocation="handleLocation(event)" accuracymode="precise"></geolocation>
function handleLocation(event) {
if (event.target.position) {
const { latitude, longitude } = event.target.position.coords;
updateMap(latitude, longitude);
} else if (event.target.error) {
console.error('Error:', event.target.error.message);
}
}
Comparalo con lo que venimos escribiendo: callback de éxito, callback de error, manejo de PermissionStatus, listener de cambios de permiso, y un estado de UI para "el usuario nos bloqueó, mostrale un cartel explicando cómo desbloquear".
Lo mismo del lado de media:
<usermedia id="media-ctrl">
<button>Habilitar cámara y micrófono</button>
</usermedia>
Ese <button> de adentro no es el label del elemento: es el fallback. Si el navegador soporta <usermedia>, no lo renderiza y dibuja su propio control; si no lo soporta, es el botón que ve el usuario. Más abajo está el patrón completo.
const el = document.getElementById('media-ctrl');
// Las preferencias de hardware se configuran ANTES de la interacción
el.setConstraints({
video: { width: 1280, height: 720 },
audio: { echoCancellation: true },
});
el.addEventListener('stream', () => {
videoPreview.srcObject = el.stream;
});
el.addEventListener('error', () => {
console.error(`Falló el acceso: ${el.error?.name}`);
});
el.addEventListener('cancel', () => {
console.log('El usuario descartó el prompt.');
});
Tres eventos —stream, error, cancel— y una propiedad stream. El getUserMedia() con su cadena de promesas y su taxonomía de DOMException queda del otro lado.
<camera> y <microphone> son la versión de capacidad única: mismo modelo, mismo flujo de recuperación, pero pidiendo solo lo que necesitás. Si tu app solo transcribe audio, pedir cámara y micrófono juntos es pedir de más, y el usuario lo nota.
Por qué el prompt funciona mejor
El mecanismo es simple: el prompt solo aparece después de un click físico sobre un elemento controlado por el navegador. Eso le da a Chrome algo que con getUserMedia() nunca tuvo: una señal confiable de intención del usuario. Y con esa señal puede saltearse los quiet blocks que hoy hacen fallar en silencio a las llamadas imperativas.
Además, si el usuario ya había bloqueado, el click no muestra un prompt normal sino un flujo de recuperación: le permite rehabilitar el permiso ahí mismo, en la página, en el momento en que efectivamente quiere usar la feature. Sin ir a settings.
Y en el caso de <geolocation>, si el permiso ya está otorgado el click funciona como refresh: pide la posición de nuevo sin volver a preguntar.
Los números que publicaron los participantes del trial son grandes. Cisco midió que los usuarios que habían denegado el permiso tenían cerca del 10% de probabilidad de terminar otorgándolo con los prompts tradicionales, y más del 65% con el elemento. Google Meet reportó 17% menos reportes de "no me anda el micrófono". Hay varios más, en el artículo original.
Vale aclarar de dónde salen: son datos de los propios participantes del trial, o sea empresas con incentivo en que la feature funcione y que además rediseñaron el flujo al adoptarla. No los leería como "el elemento multiplica por seis tu conversión". Sí como evidencia bastante sólida de que el problema no era el permiso, era el contexto: cuando el pedido aparece en el momento en que el usuario quiere la feature, la mayoría dice que sí.
El precio: el navegador te controla el botón
Un elemento que dispara un prompt de permiso es un blanco obvio para patrones oscuros: taparlo con un div, achicarlo a un pixel, o ponerlo justo donde el usuario iba a clickear "Cerrar". Así que el navegador impone restricciones y las verifica:
- Legibilidad: contraste mínimo de 3:1 entre texto y fondo, y el canal alpha tiene que ser 1. Nada de elementos semitransparentes.
- Tamaño y espaciado: hay mínimos y máximos para
width,heightyfont-size. Márgenes negativos youtline-offsetnegativo están deshabilitados, para que no puedas tapar el elemento con contenido propio. - Integridad visual:
transformsolo admite traslaciones 2D y escalados proporcionales. Nada de rotar, deformar o aplastar. - Estados: tenés pseudo-clases para estilar, incluyendo
:granted(activa cuando el permiso está otorgado y el stream adquirido) además de:hovery:active.
Y acá viene lo que conviene saber antes de tocar el CSS: si te pasás de contraste, de alpha o de font-size, el elemento se desactiva. No es que se vea mal: se sigue viendo igual, pero el click deja de hacer nada. La spec lo modela como un blocker temporal de tipo style_invalid. El resto de las propiedades se clampean o se ignoran directamente.
Y el estilo es solo uno de los motivos de desactivación. El elemento también queda bloqueado si está fuera del viewport, si está parcialmente tapado por otro contenido, si lo acabás de insertar en el DOM o si lo estás moviendo. O sea que un error de contraste, o un modal que lo tape a medias, no te rompe el diseño: te deja la feature muerta y en silencio. Tenelo en el radar cuando alguien reporte que "el botón de la cámara no anda".
Progressive enhancement
Esta parte está bien resuelta. En navegadores sin soporte, el elemento es un HTMLUnknownElement —un elemento desconocido que se renderiza como un inline genérico— y sus hijos se muestran. En navegadores con soporte, los hijos no se renderizan. Eso te da el fallback gratis:
<geolocation onlocation="updateMap()">
<!-- Solo se ve si el navegador no soporta el elemento -->
<button onclick="navigator.geolocation.getCurrentPosition(updateMap)">
Usar mi ubicación
</button>
</geolocation>
Para lógica más compleja, detección directa por interfaz:
if ('HTMLUserMediaElement' in window) {
// Camino moderno con <usermedia>
} else {
// Fallback a getUserMedia()
}
Y para <geolocation> hay un polyfill en npm que reemplaza las ocurrencias por un custom element <geo-location> respaldado por la API clásica. Si el navegador soporta el elemento nativo, el polyfill no hace nada:
if (!('HTMLGeolocationElement' in window)) {
await import('https://unpkg.com/geolocation-element-polyfill/index.js');
}
Si participaste del origin trial del <permission> genérico, la migración es mecánica: cambiás <permission type="camera microphone"> por <usermedia>, actualizás los selectores CSS y cambiás la detección de HTMLPermissionElement a HTMLUserMediaElement.
Lo que hay que mirar con cuidado
Un par de cosas que no son obvias leyendo la documentación:
El autolocate de <geolocation> no es lo que parece. Intenta obtener la ubicación al cargar, pero solo si el estado de permiso ya lo permite. O sea que no dispara prompts inesperados. El elemento tiene que estar en el DOM para funcionar, pero no necesita ser visible para que autolocate funcione. Eso habilita refrescar la ubicación de alguien que ya te la dio, sin botón. Y también habilita esconderlo y refrescarla siempre, así que ojo.
setConstraints() va antes de la interacción. Las preferencias de hardware (resolución, deviceId, cancelación de eco) se configuran sobre el elemento antes de que el usuario haga click. Una vez que tenés el stream, si necesitás cambiar algo vas por applyConstraints() sobre el track, como siempre.
Hoy solo funciona en Chromium. Mozilla dio posición positiva sobre <geolocation>, pero las posiciones sobre <usermedia> y su equivalente en WebKit siguen abiertas. Como el fallback es limpio, el costo de adoptarlo hoy es bajo, pero no cuentes con esto como tu único camino a la cámara.
Conclusión
Durante años tratamos las denegaciones de permiso como una decisión del usuario que había que respetar y listo. Los datos del trial sugieren otra cosa: buena parte de esos "no" son ruido, prompts que aparecieron cuando el usuario no tenía contexto para decidir. Y el "no" por accidente pesaba lo mismo que el "no" pensado, porque no había camino de vuelta.
Si tenés una app con cámara, micrófono o ubicación, el experimento barato es envolver tu botón actual en el Capability Element que corresponda y dejar el botón adentro como fallback. Si lo hacés, medí la tasa de recuperación antes y después: es el número que movieron todos los que participaron del trial, y es el que te va a decir si en tu caso también estaba fallando el contexto y no el permiso.

