Dónde debe situarse el límite del framework en una pila de interfaz de usuario reutilizable
Aprenda cómo las máquinas de estado, los componentes web y el diseño y movimiento basados en atributos permiten que el comportamiento de la interfaz de usuario sobreviva más allá de un framework, y cuándo esa portabilidad no vale la pena.
La mayoría de los equipos resuelven el problema de la interfaz de usuario de manera muy eficaz para un único framework. Un conjunto de componentes de Angular cuidadosamente desarrollado, un conjunto de primitivas de React, un sistema de diseño integrado al ciclo de vida de un renderizador: todo funciona maravillosamente hasta que un proyecto necesita una herramienta diferente, y entonces casi nada de esa inversión resulta útil. Dividir la interfaz de usuario reutilizable en capas (comportamiento, componentes renderizados y pequeñas capacidades HTML como el diseño de layout y los efectos de movimiento) permite decidir deliberadamente qué capas deben vincularse a un framework y cuáles no.
El problema de resolver la interfaz de usuario para un único framework
Imagínese un equipo de frontend que pasa la mayor parte de su tiempo en Angular y está satisfecho con el rumbo que ha tomado el framework: señales, APIs modernas y una gran cantidad de estructura para las aplicaciones que la necesitan. Su kit interno sigue el modelo shadcn, en el cual el código fuente de los componentes se copia a cada proyecto para que lo gestione este, y se apoya en primitivas sin interfaz específicas de Angular para las tareas de interacción a nivel bajo. Dentro de una aplicación Angular, se trata de una configuración excelente.
Los problemas comienzan cuando el próximo proyecto no es un proyecto Angular. Una aplicación grande, con estado y alta interacción puede ser la opción perfecta para Angular. Un sitio de marketing mayormente estático suele beneficiarse más con Astro. A veces, la plataforma del navegador por sí sola ya proporciona casi todo lo necesario. Si la tecnología debe adaptarse a los requisitos de cada proyecto, en lugar de al revés, entonces surge una pregunta inevitable:
¿Cuánto de la infraestructura de tu interfaz de usuario debería mantenerse cuando cambie el framework?
Investigar esa pregunta suele llevar a través de una secuencia de ideas: primero los componentes web, luego las interfaces sin cabeza, después las máquinas de estado, y finalmente un experimento menos convencional en el que el diseño y los movimientos reciben sus propios atributos HTML. A primera vista parecen no estar relacionados. Al analizarlos juntos, resultan ser respuestas diferentes a un mismo problema de diseño: hasta qué punto se puede compartir la interfaz antes de que el framework de la aplicación tenga que infiltrarse en cada abstracción?
Las interfaces sin cabeza no son lo mismo que las independientes de framework
Las interfaces sin cabeza ya resuelven una gran parte del problema. Radix Primitives es una ilustración clara de por qué este modelo ganó popularidad. En lugar de entregar un diálogo con el color de fondo, el espaciado, la sombra y el radio del borde de otra persona, entrega las partes complejas y deja que usted se ocupe del aspecto visual.
Esas partes difíciles representan un trabajo real: gestión de la atención, navegación por teclado, atributos ARIA correctos, comportamiento al cerrar y toda una serie de detalles que son fáciles de pasar por alto cuando un modal parece ser simplemente un div superpuesto a otro. Radix proporciona intencionadamente sus primitivas sin estilo y las presenta como primitivas accesibles de React. Tú controlas la presentación, pero el modelo de componentes subyacente sigue siendo React.
Eso suena obvio, pero tiene consecuencias. Eliminar la dependencia de estilos no elimina la dependencia del framework. Lo mismo ocurre en el mundo de Angular: una primitiva puede ser completamente neutral respecto a colores, espaciado y tipografía, aunque dependa por completo de las directivas, señales, inyección de dependencias y ganchos de ciclo de vida de Angular.
Esto no es una deficiencia. Muy a menudo es precisamente la elección correcta. Una primitiva diseñada para Angular puede integrarse estrechamente con Angular, y una primitiva de React puede aprovechar el modelo de composición de React. La integración profunda suele generar una mejor experiencia para el desarrollador que fingir que el framework no existe. Lo importante es que con frecuencia dos conceptos se tratan como sinónimos cuando en realidad no lo son:
headless
≠
framework-agnostic
Los componentes sin interfaz visual eliminan la consideración estética. Las abstracciones independientes del framework van un nivel más allá e intentan eliminar las suposiciones sobre el propio renderizador. Se trata de dos niveles distintos de reutilización, y es útil saber cuál se necesita realmente.
El comportamiento puede existir por debajo del componente
Tomemos un menú desplegable. Dos menús desplegables pueden no tener nada en común visualmente. Uno se encuentra en una página de marketing con tipografía grande, mucho espacio en blanco y transiciones animadas; el otro está en una barra de herramientas de IDE reducida donde cada píxel cuenta. Uno podría ser renderizado por Angular, otro por React y un tercero por un componente web.
Más allá de las diferencias visuales, siempre surgen las mismas preguntas:
- ¿Está el menú desplegable abierto actualmente?
- ¿Qué elemento está activo?
- ¿Qué función tiene la tecla Escape?
- ¿Puede el usuario moverse entre los elementos con las teclas de dirección?
- ¿Cómo se manejan los elementos deshabilitados?
- ¿A dónde va el foco una vez que se cierra el menú desplegable?
Ninguna de estas preguntas depende de si Angular o React generaron el marcado. Primero son problemas de interacción y segundo problemas de renderizado.
Máquinas de estado como contrato común
Aquí es donde la interfaz de usuario impulsada por máquinas de estado se vuelve atractiva. Zag.js es el ejemplo más conocido de este enfoque. En lugar de tratar al componente React como la fuente de verdad, Zag modela las interacciones como máquinas independientes del framework. Luego, adaptadores específicos para cada framework conectan esas máquinas a la forma en que React, Vue, Solid, Svelte y otros manejan la reactividad, el ciclo de vida y el DOM; su documentación explica cómo escribir un adaptador para un framework que aún no cubre.
En un componente convencional, cada aspecto está incluido dentro de una unidad específica del framework:
React Dropdown
├─ state
├─ interactions
├─ accessibility
└─ rendering
Con una máquina en el medio, el comportamiento se define una sola vez y cada renderizador lo utiliza:
Dropdown behavior
│
state machine
│
┌────────────┼────────────┐
↓ ↓ ↓
React Vue Svelte
Los componentes finales siguen siendo componentes separados, y cada framework sigue comportándose como tal. Lo que se ha reubicado es el contrato de comportamiento, que representa una definición más útil e independiente del framework que la idea de un componente mágico que funcione en todas partes sin necesidad de integración alguna. Un resumen mejor sería: escribir el comportamiento una vez y luego adaptarlo al renderizador que sea más adecuado para la aplicación.
Un modelo mental útil es considerar que la máquina representa una descripción pura de estados, eventos y transiciones. Dado que no contiene referencias al DOM ni estado del framework, también es fácil probarla de forma aislada: se le envían eventos y se verifica el estado resultante, sin necesidad de montar nada.
Un componente puede existir como comportamiento antes que como interfaz de usuario
La misma idea funciona dentro de una biblioteca de componentes web. Imagine una biblioteca construida con Stencil que contiene los botones, diálogos, menús desplegables y tarjetas habituales. Esos componentes no necesitan ser reemplazados por máquinas de estado; en su lugar, se puede colocar una capa separada debajo de ellos.
Una fábrica de botones conoce los estados deshabilitado y en carga, el manejo de clics y qué propiedades deben aplicarse al elemento interactivo. Una fábrica de menús desplegables conoce cómo abrirse, cerrarse, seleccionarse y la navegación por teclado. Una fábrica de diálogos conoce su ciclo de vida y sus reglas de interacción. Ninguna de estas lógicas tiene que decidir cómo se verá el componente.
Conceptualmente, algo así puede existir antes de que se tome cualquier decisión de renderizado:
const button = createButton({
disabled: false,
loading: false,
onClick(event) {
// application behavior
},
});
Observe lo que falta: no hay Stencil, ni Angular, ni componente React, ni siquiera CSS. La fábrica es solo una descripción del comportamiento, y se puede adjuntar un renderizador más tarde. Por ejemplo, el botón de Stencil de la biblioteca consume ese comportamiento y expone un Elemento Personalizado con estilo:
<and-button variant="destructive">
Delete
</and-button>
Otra aplicación puede envolver un botón completamente diferente alrededor del mismo núcleo de comportamiento. Considere una IDE de estilo de escritorio cuya interfaz es intencionalmente mucho más densa que un sitio web típico. Sus botones utilizan otras dimensiones, otros tokens de diseño y un lenguaje visual diferente, por lo que importar el botón de Componente Web de uso general sería la elección incorrecta. La IDE simplemente tiene su propio botón Angular, y ese botón Angular aún puede llamar a la misma fábrica createButton().
Ese es el beneficio práctico. El objetivo no es este:
ONE BUTTON
↓
use everywhere
El objetivo es este:
shared behavior
│
┌──────────┴──────────┐
↓ ↓
Web Component Angular component
↓ ↓
Web UI IDE UI
La presentación se mantiene local a cada producto, mientras que la tediosa lógica de interacción ya no tiene que reescribirse para cada uno de ellos.
Los Web Components hacen que el componente renderizado sea portable
Las máquinas de estado hacen que el comportamiento sea portable. No influyen en la interfaz de usuario renderizada, y ahí es donde los Web Components siguen siendo valiosos. Un elemento personalizado como el que se muestra a continuación pertenece a la Plataforma Web y no a Angular, React o Vue:
<and-button>
Save
</and-button>
Angular puede renderizarlo, Astro puede emitirlo, React puede utilizarlo, y una página HTML simple puede incluirlo con una etiqueta script. El framework que lo rodea puede cambiar mientras el elemento se mantiene exactamente igual.
En la práctica, el soporte de los frameworks para los Elementos Personalizados varía en sus detalles. Angular necesita CUSTOM_ELEMENTS_SCHEMA (o algo equivalente) para aceptar etiquetas desconocidas, y React históricamente pasaba valores como atributos en lugar de propiedades, lo que complicaba el manejo de datos complejos y eventos personalizados, aunque las versiones recientes han mejorado esto. Vale la pena verificar el soporte actual de elementos personalizados de su framework antes de decidirse por esta capa.
Se obtienen así dos tipos distintos de reutilización. Uno es el comportamiento portátil:
State machine
↓
portable behavior
El otro es un componente renderizado portátil:
Web Component
↓
portable rendered component
A veces se desea tener todo el componente web. Si un botón de un sistema de diseño debe verse y comportarse de manera idéntica en varias aplicaciones, empaquetarlo como un elemento personalizado es una opción sensata. Otras veces no se quiere el componente completo en absoluto. El escenario del entorno de desarrollo integrado es precisamente ese: mantener la lógica de interacción, pero dejar que la aplicación se encargue por completo del renderizado y el diseño.
Estas no son arquitecturas rivales; simplemente establecen los límites de la abstracción en lugares diferentes. Pensar en límites en lugar de en bibliotecas también aclara algo más: no toda pieza reutilizable de la interfaz de usuario necesita ser un componente.
El diseño no necesita un componente
El diseño es el ejemplo más claro. Aquí hay un elemento ordinario de Tailwind:
<div
class="
flex
flex-col
items-start
gap-6
p-6
rounded-xl
border
bg-card
shadow-sm
"
>
...
</div>
No hay nada malo en ello. Una de las verdaderas fortalezas de Tailwind es que se puede comprender la mayor parte de un elemento sin tener que ir y venir constantemente entre el template y la hoja de estilos. Pero fíjese en cuántas funciones cumple ese único atributo class. Algunas de las clases describen la identidad visual:
rounded-xl
border
bg-card
shadow-sm
Otras describen las relaciones espaciales entre el elemento y sus hijos:
flex
flex-col
items-start
gap-6
p-6
El navegador no hace distinción entre ellas. class es simplemente un mecanismo genérico para adjuntar identificadores que CSS y JavaScript pueden seleccionar, y HTML no tiene concepto de una “clase de diseño” frente a una “clase de layout”. Sin embargo, como decisión en el diseño de la API, separarlas resulta conveniente. Un atributo experimental and-layout expresa la parte espacial por separado:
<div
class="card"
and-layout="vertical align:start gap:lg p:lg"
>
...
</div>
La ventaja no es la brevedad; a veces esta versión no es más corta. La ventaja radica en que la responsabilidad se vuelve visible: class describe cómo se ve el elemento, y and-layout describe cómo organiza el espacio.
Cómo se implementa el atributo
La implementación no necesita ni un framework ni JavaScript. Es puro CSS: cada token del atributo se corresponde con selectores de atributos (el selector ~= para palabras separadas por espacios es una opción natural) y se relaciona con reglas de Flexbox, Grid, espaciado y diseño responsive. Una declaración como la siguiente es, en última instancia, simplemente CSS Grid con puntos de ruptura:
<div
and-layout="grid cols:1 cols@md:2 cols@lg:3 gap:lg"
>
No hay un motor de diseño oculto detrás, ni directivas de Angular, ni componentes de React, y tampoco un contenedor como este a menos que realmente se desee utilizar Stack:
<Stack direction="vertical" gap="lg">
Esa calificación es importante. Los componentes de layout no son algo negativo. Una abstracción <Stack> puede ser muy útil, especialmente dentro de un sistema de diseño de frameworks. El enfoque basado en atributos es simplemente otra opción: cuando el elemento que necesitas ya existe, quizás no sea necesario inventar un componente adicional solo para describir cómo se dispone su contenido.
Un compromiso que hay que tener en cuenta: los atributos personalizados sin el prefijo data- no son HTML válido según las especificaciones, aunque todos los navegadores los formateen sin problemas. Los validadores y algunos linters se quejarán, y una ortografía de data-and-layout evita eso a costa de un poco más de verbosidad.
class puede hacerlo todo, pero no tiene por qué
Hay un patrón más amplio detrás de esto. El marcado moderno puede cargar una gran parte de la responsabilidad en class. Al agregar CSS de utilidades y un plugin de animaciones, un elemento completamente razonable puede convertirse en esto:
<div
class="
card
flex
flex-col
items-center
gap-6
p-8
rounded-xl
border
bg-card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
Funciona, y las clases de utilidades legibles son mucho más preferibles que pretender que cada interfaz necesita una jerarquía BEM perfectamente semántica. La verdadera pregunta no es si class puede asumir todo esto; obviamente sí puede. La pregunta es si un único atributo debería representar todas las capacidades que tiene un elemento. Al dividir las responsabilidades se obtiene:
<div
class="card"
and-layout="vertical align:center gap:lg p:xl"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
El elemento ahora muestra tres responsabilidades separadas a simple vista:
class → visual identity
and-layout → spatial organization
and-motion → animation
Considérelo como una pequeña aplicación del principio de responsabilidad única al marcado. HTML no lo exige. La motivación es que el resultado resulta más fácil de leer, revisar y modificar.
El movimiento tiene su propio canal declarativo
La parte relacionada con las animaciones se basa en un patrón que ha funcionado bien durante años. Animate.css, publicado en animate.style, controla las animaciones exclusivamente a través de clases: se agrega la clase base junto con el nombre de una animación y el elemento se anima.
<h1 class="animate__animated animate__bounce">
Hello
</h1>
Es simple, familiar y prácticamente sin formalidades. Los plugins de animación orientados a Tailwind siguen la misma filosofía al convertir las animaciones en otro conjunto de utilidades componibles dentro de la clase.
La pregunta interesante aquí no es cómo crear un sistema de animaciones más potente. Esto refleja la cuestión del diseño: si las animaciones son un tema separado, pueden tener su propio espacio declarativo en el marcado. En lugar de agrupar las utilidades de animación junto con todo lo demás:
<div
class="
card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
se describe la animación por separado:
<div
class="card"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
Y cuando el disparador es importante, se indica explícitamente junto con la demora y la duración:
<div
class="card"
and-motion="slide-in-up"
and-motion-trigger="enter"
and-motion-duration="800ms"
and-motion-delay="200ms"
>
El marcado declara qué animación se ejecuta, cuándo se ejecuta y cómo se cronometra; la implementación se encarga del resto. Para un disparador enter, eso probablemente significa que un IntersectionObserver está observando el elemento. Los disparadores de hover y tap utilizan sus propios listeners. Lo crucial es que la consulta de medios prefers-reduced-motion puede respetarse en un único lugar central, en lugar de depender de que cada creador de componentes se acuerde de ella.
Los atributos no son mágicos. Lo que ofrecen es una forma para que un elemento anuncie una capacidad sin insertarlo en el árbol de componentes del framework.
Declarativo hasta que deja de ser útil
Este enfoque tiene un límite. Una animación simple de entrada cabe perfectamente en un único atributo:
and-motion="fade-in"
La transición de salida de un modal representa otro tipo de problema. Supongamos que el modal debe animarse y eliminarse del DOM únicamente después de que termine la animación. En este caso, el ciclo de vida y el orden son importantes, y una pequeña llamada imperativa expresa la intención de manera más clara:
await player.play('fade-zoom-out');
closeModal();
Intentar codificar cada relación del ciclo de vida en un atributo HTML que crece constantemente empeoraría la API declarativa, en lugar de mejorarla. La regla guía es elegir la capa más simple que represente con precisión el problema, ya sea CSS puro, un atributo, una máquina de estados, un servicio habitual de un framework o un componente web. El estilo declarativo no debe convertirse en un dogma. Nadie intenta deshacerse de JavaScript o de los frameworks. El objetivo es evitar elevar cada problema a la abstracción más compleja disponible solo porque está disponible.
Atributos como APIs de capacidades simples
Una vez que tanto el diseño como el movimiento siguen este patrón, los atributos comienzan a parecerse menos a configuraciones y más a pequeñas APIs de funcionalidades. Considere este fragmento:
<section
class="feature-card"
and-layout="vertical gap:lg p:xl"
and-motion="fade-in"
and-motion-trigger="enter"
>
<h2>Framework-agnostic UI</h2>
<p>At least as much as possible.</p>
</section>
Sigue siendo un <section>, y su significado semántico permanece intacto. Para obtener espaciado, presentación y una animación de entrada no fue necesario utilizar una serie de envoltorios como este:
<and-stack>
<and-motion-container>
<and-card>
...
</and-card>
</and-motion-container>
</and-stack>
Esa versión anidada no está automáticamente equivocada. Si esos elementos presentan un comportamiento significativo y exponen una API útil, bien podrían merecer ser componentes. La distinción es más estrecha: una capacidad reutilizable no tiene por qué convertirse automáticamente en otro componente. A menudo el nodo DOM ya existe y solo necesita un diseño, animaciones o comportamientos de tooltip. El framework no siempre tiene que estar al tanto de él, y cuando una abstracción existe directamente en HTML, puede utilizarse sin costo adicional en Angular, Astro, React, Vue o en una página sin framework alguno.
“Independiente de frameworks” no significa “sin frameworks”
Existe un riesgo evidente aquí: el enfoque “independiente de frameworks” puede convertirse fácilmente en otra competencia por la pureza, lo cual haría que se perdiera por completo el objetivo. Los frameworks ganan su relevancia al integrar diferentes componentes. Angular ofrece señales, plantillas, inyección de dependencias, formularios, enrutamiento y un modelo de aplicación claro. React cuenta con un ecosistema de composición muy maduro. Vue y Svelte realizan otros tipos de compromisos.
El comportamiento independiente de frameworks aún debe conectarse al sistema reactivo de cada aplicación. Por eso Zag incluye adaptadores de frameworks: un adaptador vincula una máquina a las convenciones de reactividad, ciclo de vida y DOM del framework, y la máquina solo puede mantenerse independiente porque otra capa comprende dicho framework.
El enfoque por capas descrito aquí conlleva los mismos costos:
- Un componente Angular construido sobre una fábrica de estado genérica puede requerir un
effect()para mantener las entradas de señales sincronizadas con el estado externo. - Un componente web debe conectar la capa de estado con sus propios callbacks de ciclo de vida.
- El diseño basado en atributos introduce un pequeño DSL que cada miembro del equipo debe aprender.
- Los atributos de movimiento añaden otra capa de API adicional.
- Dividir todo en paquetes separados crea contratos que deben mantenerse compatibles con el paso del tiempo.
A veces, un componente específico del framework y sencillo es claramente la mejor opción de diseño. Por eso, un conjunto de componentes listos para usar exclusivamente en Angular sigue teniendo sentido junto con todo esto. Nadie debería hacer pasar cada botón de Angular por una cadena de cuatro adaptadores, un par de máquinas de estado y un componente web solo porque “agnóstico” suena sofisticado. Esa es una forma muy costosa de evitar escribir:
<volt-button>
La independencia del framework rinde frutos cuando la portabilidad es un requisito real. Cuando no lo es, una integración estrecha con el framework suele ser la mejor solución.
Interfaz de usuario agnóstica al framework como pila, no como biblioteca
La expresión “biblioteca de interfaz de usuario agnóstica a frameworks” suele evocar un conjunto de componentes que funcionan de alguna manera en cualquier lugar. Los Web Components se acercan bastante a eso para los componentes renderizados. Sin embargo, un modelo más útil no es una única biblioteca universal, sino una serie de responsabilidades, cada una de las cuales se vuelve específica para un framework en un momento diferente:
Application
│
Angular / React / Vue / Astro
│
framework adapters
│
─────────────────────────────────────────
│
headless behavior / state machines
│
Web Components HTML capabilities
│ │
│ layout / motion attributes
│ │
─────────────────────────────────────────
│
Web Platform
Ninguna aplicación tiene que utilizar todas las capas:
- Un proyecto utiliza un Web Component directamente y nunca interactúa con el estado sin interfaz que se encuentra debajo.
- Otro solo utiliza la máquina de estados y construye su propia interfaz de usuario con Angular encima.
- Una página estática de Astro puede no necesitar nada más que los atributos de layout y movimiento.
- Una aplicación Angular puede ignorar toda la estructura y utilizar un kit nativo de Angular con primitivas de Angular, ya que eso brinda la experiencia de desarrollo más fluida.
La idea principal es poder combinar elementos de diferentes formas. Que algo sea independiente de un framework no significa que ese framework esté prohibido. Significa que tú decides dónde se encuentra el límite del framework, en lugar de permitir que se extienda automáticamente a cada capa reutilizable.
Mantener el framework en la capa de aplicación
Nada de esto es un argumento en contra de Angular o cualquier otro framework. Se trata de cuánta responsabilidad recibe un framework por defecto. Revisa los ejemplos nuevamente desde esa perspectiva:
- El diseño visual de un menú desplegable puede pertenecer al producto, y su renderizado a Angular, pero su modelo de interacción no tiene por qué ser así.
- Un botón compartido puede ser un componente web cuando se desea tener el mismo botón en varios proyectos, o un componente personalizado de Angular que solo comparta un núcleo de comportamiento básico cuando un producto necesita su propio lenguaje visual.
and-layout.Vistos juntos, todos estos elementos encajan perfectamente:
- Headless UI elimina la componente visual.
- Máquinas de estado liberan el comportamiento de cualquier renderizador específico.
- Componentes web permiten que un componente ya terminado y renderizado se mueva entre diferentes entornos.
- Atributos dedicados vinculan capacidades ligeras como el diseño y los efectos de movimiento directamente al HTML, en lugar de al framework.
Ninguna de estas ideas es nueva, y no todos los sistemas de interfaz de usuario deberían construirse de esta manera. Sin embargo, en conjunto desafían una práctica establecida: la idea de que el framework también debe estar presente bajo cada abstracción de interfaz reutilizable que tenga una aplicación.
Puntos clave
- Distinguir entre “sin estilo” e “independiente del renderizador”; la mayoría de las bibliotecas headless son solo del primer tipo.
- Colocar la lógica de interacción que comparten varios productos en máquinas de estado o fábricas sin framework, asumiendo conscientemente el costo que implica el adaptador.
- Usar Web Components cuando el componente renderizado en sí, y no solo su comportamiento, debe ser idéntico en diferentes entornos.
- Recurrir a las capacidades de atributos basados únicamente en CSS para el diseño y los movimientos simples, y pasar al código imperativo tan pronto como sea importante el orden del ciclo de vida.
Lecturas relacionadas
- Cuando los componentes React reutilizables fallan: Explosión de propiedades y la solución — Vea cómo el reuso prematuro convierte un componente React simple en una carga excesiva de propiedades, y cómo la duplicación, los componentes compuestos y la Regla de Tres lo evitan.
- Repensando el estado de React: Donde deberían residir realmente tus datos — Este artículo explica cómo reducir los errores en React al mover el estado a la URL, al DOM o a valores derivados en lugar de sobreutilizar useState.