wandres.dev
FORMULARIOS · Estilar lo inestilizable

Por qué los controles resisten al CSS

El árbol de sombra del navegador, qué es realmente appearance, y el inventario exacto de lo que rompes cuando escribes appearance: none.

⏱ 19 min

Un <input type="checkbox"> no es una caja con un borde y un icono. Es un punto de anclaje donde el motor de renderizado delega el dibujo al sistema operativo o a un widget interno que tú no puedes tocar, y esa decisión no fue pereza: fue la única forma de que un control se sintiera nativo en Windows 98, en macOS y en un teléfono con pantalla táctil sin que el autor de la página tuviera que saberlo. Treinta años después seguimos pagando esa factura, y el CSS de 2026 la está saldando control por control. Entender qué hay debajo es lo que separa estilar un formulario de destrozarlo.

🎯 Al terminar esta lección sabrás
  • Explicar qué es el árbol de sombra de agente de usuario y por qué no está expuesto.
  • Distinguir lo que appearance cambia de lo que no cambia.
  • Enumerar con precisión qué se pierde al escribir appearance: none en cada tipo de control.
  • Decidir cuándo reconstruir un control y cuándo teñirlo.

Qué hay debajo de un control

Cuando el parser encuentra <input type="range"> no crea un elemento y ya está. Crea el elemento, y el motor le adjunta un árbol de sombra de agente de usuario: una estructura interna de cajas anónimas —la pista, el pulgar, en algunos motores un relleno de progreso— que participa en el layout y en el paint como cualquier otra caja, pero que no aparece en el DOM que ves desde JavaScript. input.shadowRoot devuelve null. querySelectorAll('*') no encuentra nada dentro. Existe, y no existe para ti.

Esa opacidad es deliberada y tiene dos motivos. El primero es de compatibilidad: si el árbol interno fuera público, su forma se convertiría en API, y ningún motor podría volver a cambiar cómo dibuja un select sin romper páginas. El segundo es de plataforma: en iOS un <select> no se despliega, abre una rueda a pie de pantalla; en Android abre un diálogo; en escritorio abre un menú. Un árbol interno común y estable es sencillamente incompatible con esa realidad.

A lo largo de los años los motores fueron abriendo grietas puntuales en esa pared —los pseudo-elementos con prefijo de vendedor— pero la pared siguió ahí. Y encima de ella se puso una propiedad para decidir si el control se pinta con el aspecto del sistema o no.

Qué es appearance de verdad

appearance no es una propiedad de estilo, es un interruptor de motor de dibujo. Con appearance: auto, el control se pinta con el widget nativo de la plataforma y la mayoría de tus declaraciones de color, borde y fondo se ignoran o se aplican de forma parcial e impredecible, porque la que manda es la rutina del sistema. Con appearance: none, el motor apaga ese dibujo y el control pasa a comportarse como una caja CSS normal: tu background, tu border, tu border-radius empiezan a hacer efecto.

Lo importante es lo que no cambia, y es donde casi todo el mundo se confunde. appearance: none no toca la semántica. El rol de accesibilidad sigue siendo el mismo, la navegación con teclado sigue funcionando, Espacio sigue marcando la casilla, las flechas siguen moviendo el range, el control sigue enviándose con el formulario y sigue participando en la validación. Un checkbox con appearance: none sigue siendo un checkbox para el lector de pantalla.

La pérdida de accesibilidad, cuando ocurre, no la causa appearance: la causas tú al no volver a dibujar lo que el sistema dibujaba. Y lo que el sistema dibujaba es más de lo que recuerdas.

⚠️
El inventario de lo que apagas

Con appearance: none desaparecen, según el control y el motor: el indicador de marcado de la casilla, el punto del radio, la pista y el pulgar del range en Chromium, la flecha del select, la barra de progreso, el aro de foco dibujado por el sistema en algunas plataformas, la respuesta a accent-color, y —esto es lo grave— el mapeo automático a los colores del modo de alto contraste forzado de Windows. Ese último no lo ve nadie en el equipo hasta que un usuario reporta un formulario invisible.

Control por control: qué se rompe

Casilla y radio. Al apagar el dibujo te quedas con una caja vacía de tamaño intrínseco definido por el motor. Tienes que redibujar el marco, el estado marcado (:checked), el estado indeterminado (:indeterminate, que solo se activa desde JavaScript pero existe), el foco y el estado deshabilitado. La versión mínima honesta:

input[type="checkbox"] {
  appearance: none;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border: 2px solid currentColor;
  border-radius: 0.25rem;
  display: grid;
  place-content: center;
}

input[type="checkbox"]::before {
  content: "";
  inline-size: 0.7rem;
  block-size: 0.7rem;
  transform: scale(0);
  transition: transform 90ms ease-out;
  box-shadow: inset 1em 1em currentColor;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

input[type="checkbox"]:checked::before { transform: scale(1); }
input[type="checkbox"]:focus-visible { outline: 2px solid; outline-offset: 2px; }
input[type="checkbox"]:disabled { opacity: 0.5; }

Fíjate en box-shadow: inset 1em 1em currentColor en lugar de background. Es el truco que hace que la marca sobreviva al modo de alto contraste forzado, donde los fondos se sustituyen pero las sombras internas con currentColor siguen el color de texto del sistema.

select. appearance: none quita la flecha y el chaflán, pero no toca el desplegable. Ese menú sigue siendo una superficie del sistema operativo, fuera de la página, y ninguna regla CSS clásica lo alcanza. Es la fuente del malentendido más caro del CSS de formularios: la gente estila el botón, ve que funciona, asume que el resto también y descubre en producción que la lista de opciones sigue siendo gris de Windows. La respuesta real llegó con el select personalizable, que tiene su propia lección.

range. En Chromium, appearance: none deja el control literalmente invisible hasta que dibujas la pista y el pulgar con pseudo-elementos. En Gecko el reparto es distinto. Es el control con peor relación entre esfuerzo y resultado, y el catálogo de piezas está en la lección de pseudo-elementos.

Campos de texto. Aquí appearance: none casi no hace falta en escritorio, pero sí en Safari de iOS, donde el motor dibuja bordes redondeados y una sombra interna en input y textarea que ignoran tu border. Es la razón por la que medio mundo tiene un -webkit-appearance: none en su reset sin saber muy bien por qué.

button. Es el único caso donde appearance: none es prácticamente gratuito, porque un botón ya es una caja con texto centrado. Lo que sí desaparece es el estilo de pulsado de la plataforma, que debes reponer con :active.

La regla de decisión

La pregunta no es “¿puedo estilar esto?” sino “¿lo que gano justifica reconstruir todos los estados?”. Un control nativo tiene, como mínimo, seis estados visuales que el sistema mantiene por ti: reposo, hover, foco, foco visible por teclado, activo, deshabilitado. Añade marcado, indeterminado, inválido, y el modo de alto contraste. Si vas a apagar el dibujo, ese es tu contrato completo, y no puedes cumplir la mitad.

Por eso el orden correcto de las herramientas es de menor a mayor destrucción, y casi siempre te quedas en el primer escalón:

  1. Teñir sin apagar: accent-color y color-scheme. No rompen nada. Lección 49.2.
  2. Redimensionar sin apagar: field-sizing, min-inline-size, padding. Lección 49.3.
  3. Usar las grietas oficiales: los pseudo-elementos expuestos, sin appearance: none cuando el motor lo permite.
  4. Apagar y reconstruir: solo cuando el diseño lo exige de verdad, y con el inventario de estados delante.
  5. Optar por el modelo base: appearance: base-select, que no es “apagar” sino “cambiar a un aspecto base estilable y documentado”. Es lo que la plataforma quiere que hagas a partir de ahora.
appearance: none no rompe la accesibilidad; rompe el contrato implícito con el sistema operativo

Hay una asimetría que tarda años en verse y explica casi todos los formularios rotos que te vas a encontrar. Cuando un control se pinta con appearance: auto, el motor no está aplicando estilos: está delegando en una rutina del sistema que conoce cosas que tu CSS no puede conocer. Sabe qué color de acento eligió el usuario en las preferencias del sistema. Sabe si hay un tema de alto contraste activo y con qué pares de colores. Sabe si el usuario ha pedido reducir la transparencia. Sabe cuánto mide el objetivo táctil mínimo en ese dispositivo concreto. Sabe cómo se dibuja el foco en esa plataforma para que sea coherente con el resto del sistema. Nada de eso está expuesto a CSS de forma completa, y @media (forced-colors: active) o prefers-contrast solo te devuelven un fragmento pequeño de esa información. Es decir: al escribir appearance: none no estás desactivando un estilo, estás saliéndote de un sistema de adaptación que se actualiza sin ti y que cubre casos que tú nunca vas a probar. La consecuencia práctica es que un control reconstruido no es un control terminado: es un control que has adoptado y que tienes que mantener cada vez que una plataforma añada un modo de accesibilidad nuevo. Por eso la industria está desandando el camino con base-select en lugar de rendirse a los div con role="combobox": no porque los div no funcionen, sino porque nadie los mantiene al día durante diez años.

⚔️ Auditar tu propio reset

Busca en el CSS de tu proyecto todas las apariciones de appearance: none y -webkit-appearance: none. Para cada una, responde por escrito: qué control afecta, qué dibujo del sistema apaga y qué estados has redibujado. Después abre esa página en Windows con el modo de contraste alto activado, o simula el escenario en las herramientas con la emulación de forced-colors. Las que no sobrevivan son deuda, no estilo.