select en HTML: selected, optgroup, multiple y cómo estilarlo sin romperlo

El select nativo trae gratis el teclado, el lector de pantalla y el desplegable del móvil, y casi nadie lo aprovecha. Cómo se construye, cómo se marca la opción por defecto con selected, qué llega al servidor con multiple y hasta dónde se puede estilar de verdad en 2026.

Una carta de restaurante impresa, con su lista de opciones y precios
Tabla de contenidos

Pones un <select> en un formulario de alta. La primera opción dice "Selecciona una provincia". El usuario ni la toca, pulsa enviar, y en tu base de datos acaba una fila con provincia = "Selecciona una provincia". Nadie eligió nada y aun así tienes un dato basura que parece válido. El select en HTML es uno de los controles más antiguos de la plataforma y, a la vez, uno de los peor entendidos: se usa mal como etiqueta, se estila a martillazos y, cuando el diseño aprieta, se tira a la basura y se reconstruye con <div> y JavaScript perdiendo por el camino el teclado, el lector de pantalla y el desplegable nativo del móvil.

Voy a desmontar el control entero: cómo se construye, cómo se marca la opción por defecto con selected, cómo se agrupan opciones con optgroup, cómo se permite elegir varias con multiple, qué llega de verdad al servidor cuando lo haces, y hasta dónde se puede estilar hoy sin romper nada. Empiezo por lo básico, porque es exactamente donde están casi todos los errores que veo en producción.

Qué es la etiqueta select y cómo se construye

Un <select> es un control de formulario que ofrece una lista de opciones para elegir. Se compone de tres piezas: el propio <select>, que es el contenedor y el que lleva el name que viaja al servidor; los <option>, cada uno con su value; y una <label> asociada que le da nombre accesible.

<label for="provincia">Provincia</label>
<select name="provincia" id="provincia">
  <option value="">Selecciona una provincia</option>
  <option value="madrid">Madrid</option>
  <option value="barcelona">Barcelona</option>
  <option value="valencia">Valencia</option>
</select>

Dos detalles que la mayoría pasa por alto. El primero: lo que se envía al servidor es el value, no el texto visible. Si un <option> no tiene value, el navegador manda el texto interior como valor; por eso conviene poner value siempre, para controlar exactamente qué recibes. El segundo: el for de la <label> tiene que coincidir con el id del <select>, no con su name. Sin esa asociación, un lector de pantalla anuncia el control como "lista" sin decir de qué, y un clic en el texto no enfoca el campo.

Fíjate en la primera opción del ejemplo: value="". Es una opción vacía que actúa de marcador de posición. Vuelvo sobre ella enseguida, porque ahí se cuece el primer error grave.

Marcar una opción por defecto: el atributo selected

Por defecto, un <select> cerrado muestra su primer <option>. Si quieres que arranque con otra opción marcada, el atributo selected en ese <option> lo fija al cargar la página:

<select name="envio" id="envio">
  <option value="estandar">Envío estándar</option>
  <option value="express" selected>Envío exprés</option>
  <option value="recogida">Recogida en tienda</option>
</select>

selected es un atributo booleano: está o no está, selected="selected" y selected son equivalentes. Si lo pones en más de un <option> de un select normal (sin multiple), gana el último marcado y el resto se ignora.

Aquí llega el error que abría el artículo. Mucha gente usa la primera opción como si fuera la etiqueta del campo:

<!-- MAL: la primera opción hace de etiqueta -->
<select name="provincia">
  <option selected>Selecciona una provincia</option>
  <option>Madrid</option>
</select>

Ese <option> no tiene value, así que su valor enviado es el texto "Selecciona una provincia", un dato perfectamente inútil que además supera cualquier validación de "campo no vacío". Y como sustituye a una <label> de verdad, el lector de pantalla no tiene nombre estable que anunciar. La forma correcta usa una <label> aparte y deja la primera opción con value="" para que un required la rechace:

<label for="provincia">Provincia</label>
<select name="provincia" id="provincia" required>
  <option value="" disabled selected>Selecciona una provincia</option>
  <option value="madrid">Madrid</option>
  <option value="barcelona">Barcelona</option>
</select>

Con value="" y required, el navegador bloquea el envío si el usuario no cambia la opción. Con disabled sobre esa primera opción evitas además que la vuelva a elegir a propósito. No confíes solo en eso: valida siempre en el servidor, porque el atributo required es una comodidad de la interfaz, no una garantía. Lo aprendí por las malas confiando en lo que llega del cliente, un patrón que ya conté a propósito de cómo NULL rompe silenciosamente una comparación en SQL.

Agrupar opciones con optgroup

Cuando la lista crece, <optgroup> divide las opciones en categorías con un encabezado no seleccionable. El texto del grupo va en su atributo label:

<label for="plato">Plato</label>
<select name="plato" id="plato">
  <optgroup label="Entrantes">
    <option value="ensalada">Ensalada de la casa</option>
    <option value="croquetas">Croquetas</option>
  </optgroup>
  <optgroup label="Principales">
    <option value="lubina">Lubina al horno</option>
    <option value="solomillo">Solomillo</option>
  </optgroup>
</select>

El label del optgroup es puramente visual y de agrupación: no es una opción, no se puede seleccionar y no se envía nada por él. Un detalle práctico: los <optgroup> no se pueden anidar. Si necesitas jerarquías de varios niveles, el <select> no es tu control; probablemente quieras un árbol o una búsqueda con autocompletado.

Selección múltiple: los atributos multiple y size

Por defecto solo se elige una opción. El atributo booleano multiple permite elegir varias. Al ponerlo, el control deja de ser un desplegable y se convierte en una lista con scroll; el usuario selecciona con Ctrl (o Cmd en Mac) para sumar opciones sueltas y con Shift para un rango.

<label for="colores">Colores disponibles</label>
<select name="colores[]" id="colores" multiple size="4">
  <option value="rojo">Rojo</option>
  <option value="verde">Verde</option>
  <option value="azul">Azul</option>
  <option value="negro">Negro</option>
  <option value="blanco">Blanco</option>
</select>

El atributo size fija cuántas filas se ven a la vez. Según MDN, la especificación fija el valor por defecto en 1, pero Firefox devuelve 0 para no romper webs antiguas; en la práctica, si no pones size en un multiple, el alto que ves depende del navegador. Ponlo tú y no dependas del azar. size también funciona en un <select> sin multiple: con size="5" y una sola selección obtienes una lista abierta de cinco filas en lugar del desplegable clásico.

Ese gesto de Ctrl/Cmd es poco descubrible. Mucha gente no sabe que puede elegir más de uno, así que, si usas multiple, ayúdalo con un texto de apoyo cercano. Y ojo con el móvil: el selector múltiple nativo en pantallas táctiles es incómodo. Para listas largas de selección múltiple, plantéate si un grupo de checkboxes no comunica mejor que el usuario puede marcar varias.

Pantalla con un menú de ajustes y un cursor de mano señalando una opción
El desplegable que el navegador te da gratis ya sabe funcionar con teclado, con lector de pantalla y con el dedo. Reconstruirlo cuesta más de lo que parece.

Qué llega al servidor con multiple: el POST y el porqué de name="campo[]"

Aquí está la laguna que casi ningún tutorial cubre y que provoca bugs difíciles de ver. Cuando envías un <select multiple>, el navegador no manda "un array": manda un par name=value por cada opción marcada. Si el usuario elige rojo y azul, en el cuerpo de la petición viajan dos pares con el mismo nombre:

colores=rojo&colores=azul

Qué hace tu backend con dos claves repetidas depende del lenguaje, y ahí está la trampa. PHP, si el control se llama colores, procesa los pares en orden y cada uno sobrescribe al anterior: te quedas solo con azul, el último, y el rojo desaparece sin error ni aviso. La solución es la convención de PHP de añadir corchetes al name:

<select name="colores[]" multiple>
  <option value="rojo">Rojo</option>
  <option value="azul">Azul</option>
</select>

Con colores[], PHP acumula los valores en un array de verdad:

$colores = $_POST['colores'] ?? [];
// ['rojo', 'azul']

Los corchetes son sintaxis de PHP, no de HTML ni de HTTP: el navegador los trata como parte literal del nombre y envía colores[]=rojo&colores[]=azul. Otros entornos resuelven las claves repetidas de otra forma, hay frameworks que te devuelven directamente un array sin corchetes, así que comprueba el tuyo antes de dar nada por hecho.

Y el caso límite que se olvida siempre: si el usuario no marca ninguna opción de un multiple, el control no envía nada en absoluto. La clave ni aparece en el cuerpo de la petición. Por eso arriba escribo $_POST['colores'] ?? []: si esperas una clave que no llega, revientas con un aviso de índice indefinido. Un <select multiple> sin selección no manda una cadena vacía; manda silencio.

Por qué durante veinte años no se pudo estilar un select

Si alguna vez has intentado cambiar la flecha de un <select> o el aspecto del desplegable abierto y te has estrellado, no era culpa tuya. Durante casi dos décadas fue imposible de verdad, y la razón es arquitectónica.

El desplegable de un <select>, el panel que aparece con la lista de opciones, no lo pintaba el motor del navegador con CSS como el resto de la página. Lo dibujaba el propio sistema operativo, con sus widgets nativos, fuera del árbol de renderizado normal. Por eso el desplegable de un <select> en Windows se veía distinto que en macOS o en Android: no era HTML pintado, era un control del sistema invocado por el navegador. Y a lo que dibuja el sistema operativo, tu CSS no llega.

Podías tocar la caja cerrada, bordes, fondo, tipografía, con suerte variable, pero el panel abierto era territorio prohibido. Esa frontera es la que explica tanto la frustración histórica como el auge de las librerías que reemplazaban el control entero. Y también por qué lo que ha cambiado por fin en 2026 es una novedad de calado y no un truco más.

Estilar el select nativo hoy: appearance: none y tu propia flecha

La técnica clásica, soportada de forma amplia y la que debes usar como base, es appearance: none. Le dice al navegador que no pinte su estilo nativo del control, incluida la flecha. A partir de ahí dibujas la tuya, normalmente con una imagen de fondo:

.select-nativo {
  appearance: none;
  -webkit-appearance: none;
  padding: 0.5rem 2.5rem 0.5rem 0.75rem;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  background-color: #fff;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%23555' d='M4 6h8l-4 5z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.75rem center;
}

La flecha es un SVG embebido como data URI, un triángulo apuntando hacia abajo. El padding-right grande reserva el hueco para que el texto de la opción no se solape con ella. Con esto tienes control total sobre la caja cerrada y funciona prácticamente en todo lo que hay ahí fuera.

Lo que appearance: none no te da es el desplegable abierto. El panel con las opciones sigue siendo el nativo del sistema. Durante años eso fue el techo. Ya no lo es del todo, pero lo que viene ahora hay que servirlo con cabeza.

Personalizar el desplegable en 2026: appearance: base-select y sus pseudo-elementos

La novedad real es que ahora se puede estilar también el desplegable abierto: los bordes del panel, cada opción, la marca de la seleccionada y la flecha, todo con CSS normal. Se activa con un valor nuevo, appearance: base-select, que hay que poner tanto en el <select> como en su picker:

select,
::picker(select) {
  appearance: base-select;
}

Al optar por este modo, el marcado cambia: dentro del <select> metes un <button> con un <selectedcontent>, que es un clon de la opción elegida y hace de cara visible del control cerrado. El resto siguen siendo <option>:

<label for="pais">País</label>
<select id="pais" name="pais">
  <button>
    <selectedcontent></selectedcontent>
  </button>
  <option value="">Selecciona un país</option>
  <option value="es">España</option>
  <option value="fr">Francia</option>
  <option value="pt">Portugal</option>
</select>

Con eso desbloqueas pseudo-elementos que antes no existían. ::picker(select) apunta al panel desplegable entero; ::picker-icon, a la flecha; y ::checkmark, a la marca de la opción activa. Ya puedes hacer cosas que durante veinte años fueron imposibles, como girar la flecha al abrir:

select::picker-icon {
  transition: rotate 0.2s;
}
select:open::picker-icon {
  rotate: 180deg;
}

::picker(select) {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
}

option:checked {
  font-weight: 600;
}
option::checkmark {
  content: " ";
  margin-left: auto;
}

Y ahora la parte honesta, la que el marketing de los navegadores se salta. Esto no está soportado en todas partes. MDN clasifica los elementos select personalizables como de "disponibilidad limitada". Según caniuse, consultado el 10 de julio de 2026, el soporte global ronda el 68 %: lo tiene el motor de Chromium (Chrome, Edge y Opera) desde la versión 135 y Safari de escritorio desde la 27; Firefox lo trae detrás de una bandera desactivada por defecto, y Safari en iOS todavía no lo tiene. Comprueba ese enlace el día que vayas a usarlo, porque estas cifras se mueven rápido.

La buena noticia es que degrada de forma natural. Un navegador que no entiende appearance: base-select ignora ese valor y sigue mostrando el <select> nativo, que funciona perfectamente. Es decir: puedes usarlo hoy como mejora progresiva. Hoy la mayoría de tus visitantes vería el control personalizado y una parte todavía importante, Firefox y todo iPhone, vería el nativo. Ninguno vería nada roto. Lo que no debes hacer es construir tu diseño dependiendo de estos estilos, ni dar por sentado que el usuario los verá. Trátalos como un extra, pruébalos en Chromium y en un navegador sin soporte, y no los conviertas en requisito. Si necesitas hoy un desplegable idéntico en todos los navegadores, esta no es todavía tu herramienta.

El error grave: reemplazar el select por un div con JavaScript

Aquí está el fondo del asunto. Cuando el diseño exige un desplegable que la técnica clásica no permitía, la tentación clásica ha sido tirar el <select> y reconstruirlo con un <div>, una lista y JavaScript, o instalar una librería que hace justo eso. Es, casi siempre, un error.

Un <select> nativo trae gratis, de fábrica, un montón de comportamiento que cuesta muchísimo replicar bien:

  • Navegación completa por teclado: flechas para moverse, Home y End, teclear una letra para saltar a la opción que empieza por ella, Escape para cerrar.
  • Anuncio correcto en lectores de pantalla: se presenta como lo que es, con su estado y su opción activa.
  • El selector nativo del móvil, esa rueda o lista a pantalla completa que el sistema operativo ofrece y que los usuarios de móvil ya conocen.
  • Gestión del foco, cierre al hacer clic fuera, y comportamiento coherente con el resto del sistema.

Cuando lo reemplazas por un <div>, empiezas de cero con todo eso. Tienes que reconstruir a mano los roles ARIA (listbox, option), la gestión del foco, cada tecla, el estado seleccionado, el cierre por Escape y el comportamiento táctil. La inmensa mayoría de esas reimplementaciones, incluidas librerías muy populares, quedan a medias: se manejan con ratón pero cojean con teclado, confunden al lector de pantalla o pierden el desplegable nativo del móvil. Has cambiado un control accesible por defecto por uno que hay que auditar entero, y encima has metido dependencias.

La regla que sigo, y que MDN recomienda explícitamente ahora, es partir siempre del <select> nativo. Con appearance: none estilas la caja; con appearance: base-select, cuando el soporte te lo permita y como mejora progresiva, estilas también el desplegable. Reescribir el control con <div> es la última opción, no la primera, y casi nunca hace falta. Este es exactamente el tipo de decisión que separa una interfaz que funciona para todos de una que solo funciona para quien la programó; forma parte de cómo enfoco el desarrollo frontend.

Accesibilidad del select: label asociado y buenas prácticas

Resumo lo accionable, porque la accesibilidad de un <select> es sencilla si respetas cuatro cosas.

Asocia siempre una <label> con for apuntando al id del control. Es lo que da nombre al campo, permite enfocarlo al pulsar el texto y lo hace usable con lector de pantalla. No uses la primera <option> como etiqueta: rompe el nombre accesible y, si no tiene value, ensucia tus datos. Si necesitas un marcador de posición, usa una opción con value="" combinada con required, y añade disabled para que no se pueda reelegir, tal como mostré en la sección de selected.

No conviertas un <select> en menú de navegación que dispara la acción al cambiar de opción. Un <select> es para elegir un valor de un formulario, no para navegar; hacer que un change te lleve a otra página sorprende a quien usa teclado y salta de opción con las flechas. Y recuerda lo de siempre: el <select> nativo ya es accesible; cada vez que lo sustituyes por otra cosa, la accesibilidad pasa a ser tu responsabilidad, no la del navegador.

Tabla de referencia: atributos de select y option

Elemento Atributo Qué hace
<select> name Nombre del control que viaja al servidor. Con multiple, en PHP añade [] para recibir un array.
<select> multiple Booleano. Permite elegir varias opciones; el control pasa a lista con scroll.
<select> size Número de filas visibles. Por defecto 1 según la especificación (0 en Firefox).
<select> required Booleano. Exige una opción con value no vacío antes de enviar.
<select> disabled Booleano. Desactiva el control; no se envía su valor.
<select> autofocus Booleano. Enfoca el control al cargar la página.
<select> form Asocia el control a un <form> por su id, aunque esté fuera de él.
<option> value Valor que se envía. Si falta, se envía el texto interior.
<option> selected Booleano. Marca la opción por defecto al cargar.
<option> disabled Booleano. Opción no seleccionable (útil en el marcador de posición).
<optgroup> label Encabezado de grupo, no seleccionable y sin valor propio.

Ese es el select en HTML entero: un control viejo, robusto y accesible por defecto que casi nadie exprime. La mayoría de los problemas no vienen de que el <select> sea limitado, sino de pelearse con él en vez de usarlo como está pensado. Estilas la caja con appearance: none, sumas el desplegable personalizado con appearance: base-select como mejora progresiva cuando el soporte acompañe, nombras bien el control para el servidor y dejas que el navegador siga haciendo el trabajo duro del teclado, el foco y el móvil. Si en algún momento sientes que necesitas reemplazarlo por completo, para y pregúntate qué estás ganando de verdad y qué vas a tener que reconstruir a cambio.

Preguntas frecuentes

¿Cómo marco una opción como seleccionada por defecto en un select?

Se pone el atributo booleano selected en el option que quieras que aparezca marcado al cargar la página, por ejemplo . Si no pones selected en ninguno, el navegador muestra por defecto el primer option de la lista. En un select normal (sin multiple), si marcas selected en varios options gana el último; solo en un select con multiple tiene sentido preseleccionar varios.

¿Cómo pongo selección múltiple en un select de HTML?

Se añade el atributo booleano multiple al select. El control deja de ser un desplegable y se convierte en una lista con scroll: el usuario suma opciones sueltas con Ctrl (Cmd en Mac) y selecciona un rango con Shift. Conviene fijar también size para controlar cuántas filas se ven. Como el gesto de Ctrl es poco descubrible y en móvil resulta incómodo, para listas largas suele comunicar mejor un grupo de checkboxes.

¿Por qué con multiple solo me llega un valor al servidor?

Porque un select multiple no envía un array: manda un par name=value por cada opción marcada (por ejemplo colores=rojo&colores=azul). PHP procesa los pares repetidos sobrescribiendo, así que te quedas con el último. La solución es nombrar el control con corchetes, name="colores[]", y entonces PHP construye un array de verdad. Ten en cuenta además que si el usuario no marca ninguna opción, la clave no llega en absoluto: recoge el valor con algo como $_POST['colores'] ?? [].

¿Cómo cambio la flecha de un select con CSS?

Se aplica appearance: none (con el prefijo -webkit-appearance: none) al select para que el navegador no pinte su flecha nativa, y luego se dibuja la propia como imagen de fondo, normalmente un SVG embebido como data URI colocado a la derecha con background-position. Hay que reservar hueco con padding-right para que el texto no se solape con la flecha. Esta técnica está soportada de forma amplia y controla la caja cerrada del select, pero no el panel desplegable abierto.

¿Ya se puede personalizar el desplegable del select con CSS en 2026?

En parte. Existe appearance: base-select, que activado en el select y en su ::picker(select) permite estilar también el panel abierto, cada option, la flecha (::picker-icon) y la marca de la opción activa (::checkmark), usando un

¿Debo reemplazar el select por un div con JavaScript para estilizarlo?

Casi nunca. El select nativo trae gratis navegación por teclado, anuncio correcto en lectores de pantalla, gestión de foco y el selector nativo del móvil. Al reconstruirlo con un div tienes que reimplementar todo eso a mano (roles ARIA, teclas, foco, cierre por Escape, comportamiento táctil), y la mayoría de esas versiones quedan a medias. Lo recomendable es partir siempre del select nativo, estilar la caja con appearance: none y, cuando el soporte acompañe, el desplegable con appearance: base-select.

¿Te ha servido?

Si tienes un problema parecido y quieres una opinión técnica, cuéntamelo. Te respondo en menos de 24 h laborables.

WhatsApp