Selector de selección animado

Funciones web: <select> personalizable

La API de selección personalizable ofrece una forma declarativa y controlada por CSS de animar los elementos <select> y sus selectores desplegables. Si combinas appearance: base-select con técnicas modernas de animación de CSS, como @starting-style y el comportamiento de transición allow-discrete, puedes crear transiciones de IU fluidas y de alta calidad para los elementos de la capa superior sin depender de bibliotecas de JavaScript pesadas.

Anteriormente, era imposible animar los menús desplegables de selección nativos porque su IU se renderizaba fuera de las restricciones de viewport accesibles. Con appearance: base-select, el selector se puede diseñar y animar como cualquier otro elemento de página.

Cómo implementarla

Para implementar un selector animado, haz lo siguiente:

  1. Habilita la personalización: Aplica appearance: base-select al elemento <select> y al seudoelemento ::picker(select).
  2. Habilita las transiciones de ajuste de tamaño automático (opcional): Define interpolate-size: allow-keywords (por lo general, en :root) para permitir que el navegador realice la transición entre valores de métricas discretos, como height: auto y height: 0.
  3. Anima el contenedor de la capa superior: Aplica estilos de entrada y salida estándar a ::picker(select). Para asegurarte de que la transición de opacidad funcione cuando se mueva entre display: none y display: block, debes usar transition-behavior: allow-discrete (a menudo, se escribe intercalado como transition: display 0.3s allow-discrete).
  4. Conéctate al estado de apertura con @starting-style: Usa @starting-style para definir los estilos de referencia que el navegador debe calcular antes de que comience la transición. Por ejemplo, si quieres que aparezca gradualmente, establece la opacidad en 0 dentro del bloque @starting-style.
  5. Rota el ícono: Usa selectores de enfoque o activos de pseudoelementos, como :open::picker-icon, para aplicar transiciones (como rotación o traslación) al indicador de flecha.

Código de ejemplo: Selección, escalamiento y atenuación suaves

En el siguiente ejemplo, se muestra una selección personalizada con animaciones de página estándar para el contenedor del selector.

<!-- Always use a <label> linked via 'for' to the select for accessibility -->
<label for="theme-select">Visual Theme</label>
<select id="theme-select" class="animated-select" name="theme">
  <!-- The <button> inside <select> becomes the visible trigger when appearance: base-select is used -->
  <button>
    <!-- <selectedcontent> automatically displays the content of the chosen <option> -->
    <selectedcontent></selectedcontent>
  </button>
  <option value="system">
    <!-- MANDATORY: Decorative inline SVGs MUST set aria-hidden="true" to prevent redundant screen reader announcement -->
    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
      <line x1="8" y1="21" x2="16" y2="21"></line>
      <line x1="12" y1="17" x2="12" y2="21"></line>
    </svg>
    System Default
  </option>
  <option value="light">
    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <circle cx="12" cy="12" r="5"></circle>
      <line x1="12" y1="1" x2="12" y2="3"></line>
      <line x1="12" y1="21" x2="12" y2="23"></line>
      <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
      <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
      <line x1="1" y1="12" x2="3" y2="12"></line>
      <line x1="21" y1="12" x2="23" y2="12"></line>
      <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
      <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
    </svg>
    Light UI
  </option>
</select>
/* Opt-in to customizable select */
.animated-select,
.animated-select::picker(select) {
  appearance: base-select;
}

/* Enable auto-keyword transitions (usually set globally at :root) */
:root {
  interpolate-size: allow-keywords;
}

/* Style the visible trigger and icon rotation */
.animated-select {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 0.875rem 1rem;
  font-size: 1rem;
  border-radius: 8px;
  cursor: pointer;
  transition: border-color 0.2s ease;
}

.animated-select::picker-icon {
  transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}

.animated-select:open::picker-icon {
  transform: rotate(180deg);
}

/*
 * The Picker Container
 * Uses top-layer animations with `allow-discrete` visibility hooks
 */
.animated-select::picker(select) {
  background: white;
  border-radius: 12px;
  box-shadow: 0 10px 25px -3px rgba(0,0,0,0.1);
  padding: 0.5rem;
  margin-top: 0.25rem;
  width: anchor-size(width);
  overflow: hidden;

  /* The crucial transition setting for popover animations */
  transition:
    display 0.4s allow-discrete,
    overlay 0.4s allow-discrete,
    opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
    height 0.4s cubic-bezier(0.16, 1, 0.3, 1);
  opacity: 0;
  height: 0;
}

/* Open State */
.animated-select:open::picker(select) {
  opacity: 1;
  height: auto;
}

/* @starting-style to hook the transition on initial popover open */
@starting-style {
  .animated-select:open::picker(select) {
    opacity: 0;
    height: 0;
  }
}

/* Support for SVG inside Options and Selected Content */
.animated-select option svg,
.animated-select selectedcontent svg {
  flex-shrink: 0; /* Prevent icons from shrinking */
  width: 1.25rem;
  height: 1.25rem;
}

/* MANDATORY: Provide multiple indicators (e.g. bold font and distinct background) for the checked state to avoid color-only state communication */
.animated-select option:checked {
  font-weight: 700;
  background-color: #f1f5f9;
}

/* Ensure copy-paste safety for users with motion sensitivities */
@media (prefers-reduced-motion: reduce) {
  .animated-select::picker(select),
  .animated-select::picker-icon {
    transition: none !important;
  }
}

Implementación estratégica y prácticas recomendadas

  • SÍ usa @starting-style cuando necesites que las animaciones se activen exactamente cuando un elemento pase de display: none a visible.
  • NO uses el bloqueo de desplazamiento ad hoc. Los elementos de la capa superior administrados por "base-select" deben permitir comportamientos naturales de descarte del fondo.
  • SÍ verifiques las preferencias de movimiento reducido. Siempre une las restricciones de animación en una consulta de medios prefers-reduced-motion para garantizar entornos accesibles para las personas afectadas por el mareo por movimiento.
  • Prueba el comportamiento del diseño. Si configuras appearance: base-select, se quita el comportamiento predeterminado del navegador de ajustar el tamaño del elemento select en función del ancho de la opción más larga. Es posible que debas establecer un ancho fijo o usar restricciones de flex/grid para evitar cambios en el diseño.
  • Asegúrate de que tu <select> tenga un atributo name y un <label> asociado. Esto garantiza que, incluso con una IU personalizada, el componente siga siendo accesible para los lectores de pantalla y funcione correctamente con los envíos de formularios estándar.
  • Usa atributos value estándar en las opciones para asegurarte de que el envío del formulario funcione exactamente como antes.

Estrategias de resguardo

Resguardos y compatibilidad de navegadores para <select> personalizable

En el caso de los navegadores que aún no admiten appearance: base-select, el elemento <select> se degrada de forma elegante a un menú desplegable estándar del sistema operativo.

  • Se ignora el contenido que no es de texto: Los navegadores más antiguos quitan las etiquetas HTML (como <svg> o <div>) dentro de las etiquetas <option> y renderizan solo los nodos de texto. Asegúrate de que el contenido de texto de <option> sea legible y significativo por sí solo.
  • Manejo de la estructura HTML: Los analizadores estándar pueden ignorar las etiquetas <button> y <selectedcontent> dentro de <select> o tratarlas como no válidas. No se requieren polyfills de JavaScript pesados para la mejora progresiva si ves el texto estándar como un resguardo legible.
document.addEventListener("DOMContentLoaded", () => {
  // Check if browser supports base-select value
  if (!CSS.supports("appearance", "base-select")) {
    // Custom select overrides are not supported natively.
  }
});