Edición en el navegador con contentEditable
Edición contenteditable en el navegador: activa texto en línea, captura eventos input, evita los límites de execCommand y previene XSS.
Cualquier elemento HTML se vuelve editable en su lugar al añadir el atributo contenteditable — sin controles de formulario, sin librerías, sin dependencias.
Si alguna vez has construido un formulario completo solo para permitir que alguien renombre un encabezado, la primera vez que pruebas esto parece un truco de magia.
El navegador convierte el elemento en un host de edición, posiciona el cursor y permite al usuario escribir directamente en el DOM renderizado. Esto hace de contenteditable la forma más rápida de implementar un encabezado editable, un campo de edición al hacer clic, o un área de notas ligera. Sin embargo, también conlleva aristas problemáticas que la referencia del atributo nunca menciona: no existe un evento change nativo, el marcado que genera varía entre navegadores, la antigua API de formato está obsoleta, y renderizar el resultado para otros usuarios es un vector clásico de XSS. Este artículo explica cómo activarlo, capturar y persistir ediciones correctamente, gestionar esos casos límite, y decidir cuándo recurrir a otra solución.
Puntos clave
- El atributo
contenteditableacepta tres valores:true(o una cadena vacía) hace que un elemento sea editable,falselo deshabilita, yplaintext-onlypermite editar texto sin formato mientras elimina el formato de texto enriquecido. contentEditableno tiene un eventochangenativo. Escucha el eventoinput, que se dispara ante cada modificación en el host de edición.document.execCommand()para negrita, cursiva y enlaces está obsoleto y no es estándar; usa las APIs Selection y Range junto conbeforeinput/input, o una librería de editor dedicada, para texto enriquecido real.- Nunca escribas HTML introducido por el usuario a través de
contenteditablede vuelta en la página sin sanearlo — usa DOMPurify, o el método nativosetHTML()del navegador donde esté disponible, con DOMPurify como alternativa. contenteditable="plaintext-only"ya es compatible con todos los navegadores principales, habiéndose incorporado en Firefox 136 (marzo de 2025) junto al soporte ya existente en Chromium y WebKit.
Cómo activarlo: el atributo contenteditable
El atributo global contenteditable acepta tres valores, y elegir el correcto es la mayor parte de la batalla. true (o una cadena vacía) hace que un elemento sea editable; false lo deshabilita; y plaintext-only hace que el texto sin formato sea editable mientras deshabilita el formato de texto enriquecido. Según la referencia de contenteditable en MDN, es un atributo enumerado, no booleano: un valor ausente o inválido hereda la editabilidad del elemento padre.
La versión de una sola línea:
<h1 contenteditable="true">Edit this heading</h1>
Para un campo de solo texto (un título renombrable, una entrada de etiquetas, una nota de una sola línea), se recomienda plaintext-only. Bloquea el formato enriquecido pegado desde el origen: el contenido pegado en un elemento con contenteditable="true" conserva todo el formato, mientras que el contenido pegado en contenteditable="plaintext-only" tiene todo el formato eliminado.
Activa o desactiva la edición desde JavaScript mediante la propiedad contentEditable (en camelCase):
const el = document.querySelector('#note');
el.contentEditable = 'plaintext-only'; // or 'true' / 'false'
¿Cómo se capturan y persisten las ediciones de contenteditable?
Discover how at OpenReplay.com.
contentEditable no tiene un evento change nativo. Para capturar ediciones, escucha el evento input, que se dispara ante cada modificación en el host de edición. Este es el error más común en tutoriales más antiguos, que recurren a keypress o keyup y no detectan operaciones de pegado, arrastrar y soltar, ni entrada IME. Lee element.innerHTML cuando necesites preservar el formato, o element.textContent cuando quieras texto sin formato; luego persiste y restaura al cargar.
const el = document.querySelector('#note');
// Restore on load
el.textContent = localStorage.getItem('note') ?? '';
// Debounced persistence on every edit
let t;
el.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => {
localStorage.setItem('note', el.textContent);
// or: fetch('/api/note', { method: 'POST', body: el.textContent })
}, 400);
});
Sustituye textContent por innerHTML si almacenas marcado enriquecido, pero lee primero la sección de seguridad, porque esa elección es la que convierte un campo de notas en una superficie de ataque. Para un control más preciso, el evento beforeinput se dispara antes de que el DOM mute y permite inspeccionar o cancelar una edición; aplica tanto a elementos contenteditable como a cualquier elemento en designMode.
Las aristas problemáticas
Aquí es donde contenteditable se gana su reputación. Tres problemas aparecen en producción.
Marcado inconsistente y desordenado. Los navegadores no se ponen de acuerdo sobre el HTML que produce una región contenteditable, por lo que el resultado guardado rara vez es tan limpio como se espera. Como documentó Scott O’Hara, Safari históricamente ha envuelto los saltos de línea en elementos <div> mientras que Firefox inserta elementos <br>, y <div> es un hijo inválido de <p>, lo que provoca inconsistencias de renderizado si hiciste editable un párrafo. Un escenario de fallo habitual en producción es un usuario que pega contenido desde Word o Google Docs y arrastra una maraña de envoltorios <span> y estilos en línea; las repeticiones de sesión de estas sesiones de edición son una forma de observar directamente cómo se genera ese marcado malformado, en lugar de tener que deducirlo a partir de una fila corrupta en la base de datos. La solución técnica es preferir plaintext-only, o sanear en los eventos input/paste.
execCommand está obsoleto. document.execCommand(), utilizado durante mucho tiempo para el formato de negrita, cursiva y enlaces, está ahora obsoleto y no es estándar según MDN, por lo que no debes construir nuevas funcionalidades de texto enriquecido sobre él. Sobrevive en código heredado porque no existe un reemplazo completo listo para usar. MDN señala que aún preserva de forma única el historial de deshacer. Para trabajo nuevo, recurre a las APIs Selection y Range junto con beforeinput/input. Sé honesto sobre el coste: son primitivas de bajo nivel, no un reemplazo directo, y el comportamiento de Range difiere entre navegadores. Para cualquier cosa no trivial, usa un framework de editor específico.
XSS. Nunca renderices HTML introducido por el usuario a través de contenteditable de vuelta a otros usuarios sin sanearlo primero. Una escritura de innerHTML sin sanear es un vector de inyección directo. Sanea con DOMPurify (mantenido activamente, versión actual 3.x), o usa la API nativa Sanitizer del navegador donde esté disponible, con DOMPurify como alternativa:
function safeRender(el, html) {
if ('setHTML' in Element.prototype) {
el.setHTML(html); // native, strips scripts/handlers
} else {
el.innerHTML = DOMPurify.sanitize(html);
}
}
La ruta nativa es genuinamente nueva. Firefox 148, lanzado el 24 de febrero de 2026, añadió soporte para la API HTML Sanitizer junto con métodos como setHTML(), que sanea el HTML antes de insertarlo en el DOM para reducir el riesgo de ataques XSS. Chrome y Edge le han seguido, pero setHTML() aún no forma parte de Baseline, así que mantén la alternativa. El artículo de OpenReplay sobre el primer vistazo a la API HTML Sanitizer cubre los detalles técnicos en profundidad.
Accesibilidad
Una región editable debe comportarse como un control real. Añade estilos :focus visibles para que los usuarios de teclado puedan ver dónde está el cursor, y etiqueta la región. Los elementos contenteditable no tienen un nombre accesible implícito, así que añade un aria-label o una etiqueta asociada:
[contenteditable]:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
<div contenteditable="plaintext-only" aria-label="Note body" role="textbox"></div>
Los elementos editables son enfocables y participan en la navegación secuencial por teclado, aunque los elementos editables anidados no se añaden al orden de tabulación por defecto. Gestiona el foco cuando tu interfaz cambie: si un botón desaparece después de que el usuario lo active (un control de deshacer que alterna a rehacer, por ejemplo), mueve el foco de vuelta a un elemento visible con .focus() para que los usuarios de teclado no queden desorientados, un punto que Scott O’Hara señala en su implementación de deshacer/rehacer.
¿Cuándo usar contenteditable y cuándo no?
Usa contenteditable para ediciones en línea ligeras: un encabezado editable, un campo de edición al hacer clic, un editor de código/vista previa en vivo. Recurre a un control de formulario estándar cuando necesites una entrada fiable y predecible, y a un framework de editor dedicado cuando necesites texto enriquecido estructurado con una salida limpia.
| Necesidad | Mejor herramienta |
|---|---|
| Texto sin formato de una o varias líneas, envío de formulario | <input> / <textarea> |
| Edición en línea de contenido mostrado, texto sin formato | contenteditable="plaintext-only" |
| Editor de código/vista previa en vivo en el navegador | contenteditable |
| Texto enriquecido fiable, contenido estructurado/colaborativo | Librería de editor (ProseMirror, Lexical, Tiptap) |
La decisión depende de la predictibilidad de la salida. Un <textarea> te proporciona una cadena limpia y un evento change real; contenteditable te da HTML renderizado cuya forma exacta depende del navegador y de lo que el usuario haya pegado. Una librería de editor madura existe precisamente porque dominar esa salida (marcado normalizado, modelo de documento, historial de deshacer, saneamiento) es un problema complejo que alguien ya ha resuelto.
Recurre a contenteditable cuando la superficie de edición sea pequeña y la salida sea texto sin formato o desechable. En el momento en que necesites HTML estructurado de confianza, o bien restringe la entrada con plaintext-only y saneamiento, o delega el trabajo en una herramienta diseñada específicamente para ello.
Preguntas frecuentes
¿Dispara contenteditable un evento change cuando el usuario termina de editar?
No. Un elemento contenteditable no tiene un evento change nativo, razón por la cual los tutoriales más antiguos que usan keypress o keyup no detectan operaciones de pegado, arrastrar y soltar, ni entrada IME. Escucha en su lugar el evento input, que se dispara ante cada modificación en el host de edición independientemente de cómo se haya realizado el cambio. Si necesitas interceptar o cancelar una edición antes de que el DOM mute, usa el evento beforeinput, que también aplica a elementos contenteditable.
¿Debo usar contenteditable o un textarea para un campo de texto multilínea?
Usa un textarea para texto sin formato que planees enviar o almacenar, ya que devuelve una cadena limpia y dispara un evento change real. Recurre a contenteditable solo cuando necesites edición en línea del contenido mostrado directamente, en lugar de un control de formulario separado. Si el campo es solo de texto, contenteditable='plaintext-only' es la opción más adecuada, ya que elimina el formato enriquecido pegado desde el origen mientras edita el contenido renderizado directamente.
¿Es seguro seguir usando execCommand para el formato de negrita y cursiva?
No construyas nuevas funcionalidades de texto enriquecido sobre execCommand; MDN lo marca como obsoleto y no estándar. Sobrevive en código heredado porque no existe un reemplazo completo listo para usar y porque preserva de forma única el historial de deshacer del navegador. Para trabajo nuevo, usa las APIs Selection y Range junto con los eventos beforeinput e input, aunque estas son primitivas de bajo nivel con un comportamiento de Range que difiere entre navegadores. Para cualquier cosa no trivial, usa una librería de editor dedicada.
¿Funciona contenteditable plaintext-only en Firefox?
Sí. El valor plaintext-only se incorporó en Firefox 136 (marzo de 2025), haciéndolo compatible con todos los navegadores principales junto al soporte ya existente en Chromium y WebKit. Hace que el texto sin formato sea editable mientras deshabilita el formato de texto enriquecido, por lo que el contenido pegado en un elemento plaintext-only tiene todo el formato eliminado. Esto lo convierte en la opción más limpia para campos de solo texto, ya que bloquea el marcado desordenado pegado desde el origen en lugar de requerir que lo sanees después.
Gain control over your UX
See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k