This article is published in English.
Where the Framework Boundary Belongs in a Reusable UI Stack
Learn how state machines, Web Components and attribute-based layout and motion let UI behavior outlive a framework, and when that portability is not worth it.
Most teams solve UI very well for exactly one framework. A carefully built Angular component kit, a set of React primitives, a design system wired into one renderer's lifecycle: all of it works beautifully until a project needs a different tool, and then almost none of that investment comes along. Breaking reusable UI into layers (behavior, rendered components, and small HTML capabilities such as layout and motion) lets you decide deliberately which layers should be tied to a framework and which should not.
The problem with solving UI for a single framework
Picture a frontend team that spends most of its time in Angular and likes where the framework has gone: signals, modern APIs, and plenty of structure for applications that need it. Their internal kit follows the shadcn model, where component source is copied into each project and then owned by it, and it leans on Angular-specific headless primitives for the low-level interaction work. Inside an Angular application, that is an excellent setup.
The trouble starts when the next project is not an Angular project. A large, stateful, interaction-heavy application may be a perfect fit for Angular. A mostly static marketing site is often better served by Astro. Sometimes the browser platform alone already provides nearly everything required. If the technology is supposed to follow the requirements of each project, rather than the reverse, then a follow-up question becomes unavoidable:
How much of your UI infrastructure should survive when the framework changes?
Chasing that question tends to lead through a sequence of ideas: Web Components first, then headless UI, then state machines, and finally a less conventional experiment where layout and motion get their own HTML attributes. They look unrelated at first. Examined together, they turn out to be different answers to one design problem: how far can UI be shared before the application framework has to leak into every abstraction?
Headless is not the same as framework-agnostic
Headless UI already solves a large part of the problem. Radix Primitives is a clear illustration of why the model caught on. Instead of shipping a Dialog with someone else's background color, spacing, shadow and border radius, it ships the hard parts and leaves the appearance to you.
Those hard parts are real work: focus management, keyboard navigation, correct ARIA attributes, dismissal behavior, and the long tail of details that are easy to dismiss while a modal still seems like nothing more than one div layered over another. Radix intentionally delivers its primitives unstyled and positions them as accessible React primitives. You control the presentation, yet the component model underneath remains React.
That sounds obvious, but it has consequences. Taking away the styling dependency does not take away the framework dependency. The same holds in the Angular world: a primitive can be completely neutral about colors, spacing and typography while depending entirely on Angular directives, signals, dependency injection and lifecycle hooks.
This is not a flaw. Very often it is precisely the right choice. A primitive designed for Angular can integrate tightly with Angular, and a React primitive can exploit React's composition model. Deep integration usually produces a better developer experience than pretending the framework is not there. The point is only that two ideas frequently get treated as synonyms when they are not:
headless
≠
framework-agnostic
Headless components drop the visual opinion. Framework-agnostic abstractions go one level deeper and try to drop assumptions about the renderer itself. They are two distinct levels of reuse, and it helps to know which one you actually need.
Behavior can live below the component
Take a dropdown. Two dropdowns may share nothing visually. One sits on a marketing page with large type, generous whitespace and animated transitions; the other lives in a cramped IDE toolbar where every pixel counts. One might be rendered by Angular, another by React, a third by a Web Component.
Underneath the visual differences, the same questions keep coming back:
- Is the dropdown currently open?
- Which item is active?
- What does Escape do?
- Can the user move between items with the arrow keys?
- How are disabled items handled?
- Where does focus go once the dropdown closes?
None of these questions depends on whether Angular or React produced the markup. They are interaction problems first and rendering problems second.
State machines as the shared contract
This is where state-machine-driven UI becomes compelling. Zag.js is the most widely known example of the approach. Rather than treating the React component as the source of truth, Zag models interaction as framework-agnostic machines. Separate framework adapters then wire those machines into how React, Vue, Solid, Svelte and others handle reactivity, lifecycle and the DOM, and its documentation explains how to write an adapter for a framework it does not yet cover.
In a conventional component, every concern is bundled inside one framework-specific unit:
React Dropdown
├─ state
├─ interactions
├─ accessibility
└─ rendering
With a machine in the middle, the behavior is defined once and each renderer consumes it:
Dropdown behavior
│
state machine
│
┌────────────┼────────────┐
↓ ↓ ↓
React Vue Svelte
The final components are still separate components, and each framework still gets to behave like a framework. The thing that relocated is the behavioral contract, which is a more useful definition of framework-agnostic than the fantasy of one magical component that runs everywhere without any integration work. A better summary is: write the behavior once, then adapt it to whichever renderer makes sense for the application.
A useful mental model is that the machine is a pure description of states, events and transitions. Because it holds no DOM references and no framework state, it is also easy to unit test in isolation: you send it events and assert on the resulting state, without mounting anything.
A component can exist as behavior before it exists as UI
The same idea works inside a Web Component library. Imagine a library built with Stencil that contains the usual buttons, dialogs, dropdowns and cards. Those components do not need to be replaced by state machines. Instead, a separate layer can sit underneath them.
A button factory knows about disabled and loading states, click handling, and which properties should eventually land on the interactive element. A dropdown factory knows about opening, closing, selection and keyboard navigation. A dialog factory knows about its lifecycle and interaction rules. None of that logic has to decide what the component looks like.
Conceptually, something like this can exist before any rendering decision is made:
const button = createButton({
disabled: false,
loading: false,
onClick(event) {
// application behavior
},
});
Notice what is missing: no Stencil, no Angular, no React component, not even CSS. The factory is only a description of behavior, and a renderer can be attached later. The library's Stencil button, for instance, consumes that behavior and exposes a styled Custom Element:
<and-button variant="destructive">
Delete
</and-button>
A different application can wrap a completely different button around the same behavioral core. Consider a desktop-style IDE whose interface is intentionally far denser than a typical website. Its buttons use other dimensions, other design tokens and a different visual language, so importing the general-purpose Web Component button would be the wrong call. The IDE simply has its own Angular button, and that Angular button can still call the same createButton() factory.
That is the practical payoff. The goal is not this:
ONE BUTTON
↓
use everywhere
The goal is this:
shared behavior
│
┌──────────┴──────────┐
↓ ↓
Web Component Angular component
↓ ↓
Web UI IDE UI
Presentation stays local to each product, while the tedious interaction logic no longer has to be rewritten for every one of them.
Web Components make the rendered component portable
State machines make behavior portable. They do nothing for the rendered UI, and that is where Web Components remain valuable. A Custom Element like the one below belongs to the Web Platform rather than to Angular, React or Vue:
<and-button>
Save
</and-button>
Angular can render it, Astro can emit it, React can use it, and a plain HTML page can include it with a script tag. The framework around it may change while the element stays exactly the same.
In practice, framework support for Custom Elements varies in the details. Angular needs CUSTOM_ELEMENTS_SCHEMA (or an equivalent) to accept unknown tags, and React historically passed values as attributes rather than properties, which complicated rich data and custom events, although recent releases have improved this. It is worth checking your framework's current Custom Elements support before committing to this layer.
You end up with two distinct kinds of reuse. One is portable behavior:
State machine
↓
portable behavior
The other is a portable rendered component:
Web Component
↓
portable rendered component
Sometimes you want the whole Web Component. If a design system's Button must look and behave identically across several applications, packaging it as a Custom Element is a sensible choice. Other times you do not want the full component at all. The IDE scenario is exactly that case: keep the interaction logic, but let the application own rendering and design completely.
These are not rival architectures; they simply draw the abstraction boundary in different places. Thinking in boundaries rather than in libraries also clarifies something else: not every reusable piece of UI needs to be a component.
Layout does not need a component
Layout is the clearest example. Here is an ordinary Tailwind element:
<div
class="
flex
flex-col
items-start
gap-6
p-6
rounded-xl
border
bg-card
shadow-sm
"
>
...
</div>
Nothing is wrong with it. One of Tailwind's real strengths is that you can understand most of an element without jumping back and forth between template and stylesheet. But look at how many jobs that single class attribute performs. Some of the classes describe visual identity:
rounded-xl
border
bg-card
shadow-sm
Others describe spatial relationships between the element and its children:
flex
flex-col
items-start
gap-6
p-6
The browser draws no distinction between them. class is simply a generic mechanism for attaching identifiers that CSS and JavaScript can select on, and HTML has no notion of a "layout class" versus a "design class". As an API design decision, though, separating the two turns out to be pleasant. An experimental and-layout attribute expresses the spatial part on its own:
<div
class="card"
and-layout="vertical align:start gap:lg p:lg"
>
...
</div>
The benefit is not brevity; sometimes this version is no shorter. The benefit is that responsibility becomes visible: class describes what the element looks like, and and-layout describes how it arranges space.
How the attribute is implemented
The implementation needs neither a framework nor JavaScript. It is pure CSS: each token in the attribute is matched with attribute selectors (the ~= whitespace-separated-word selector is a natural fit) and mapped onto Flexbox, Grid, spacing and responsive rules. A declaration such as the following is, in the end, just CSS Grid with breakpoints:
<div
and-layout="grid cols:1 cols@md:2 cols@lg:3 gap:lg"
>
There is no layout engine hidden behind it, no Angular directive, no React component, and no wrapper like this one unless you actually want a Stack:
<Stack direction="vertical" gap="lg">
That qualification matters. Layout components are not bad. A <Stack> abstraction can be very convenient, especially inside a framework design system. The attribute approach is simply another option: when the element you need already exists, you may not have to invent an extra component just to describe how its children are laid out.
A trade-off to keep in mind: custom attributes without a data- prefix are not valid HTML according to the spec, even though every browser happily styles them. Validators and some linters will complain, and a data-and-layout spelling avoids that at the cost of a little verbosity.
class can do everything, but it does not have to
There is a broader pattern behind this. Modern markup can load a huge share of responsibility onto class. Add utility CSS and an animation plugin, and a completely reasonable element can grow into this:
<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
"
>
It works, and readable utility classes are far preferable to pretending every interface needs a perfectly semantic BEM hierarchy. The real question is not whether class can carry all of this; it obviously can. The question is whether a single attribute should stand for every capability an element has. Splitting the concerns gives you:
<div
class="card"
and-layout="vertical align:center gap:lg p:xl"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
The element now shows three separate concerns at a glance:
class → visual identity
and-layout → spatial organization
and-motion → animation
Think of it as a small application of the single-responsibility principle to markup. HTML does not demand it. The motivation is that the result is easier to read, review and change.
Motion gets its own declarative channel
The animation side builds on a pattern that has worked well for years. Animate.css, published at animate.style, drives animations entirely through classes: add the base class plus the name of an animation and the element animates.
<h1 class="animate__animated animate__bounce">
Hello
</h1>
It is simple, familiar and nearly ceremony-free. Tailwind-oriented animation plugins follow the same philosophy by turning animation into another group of composable utilities in class.
The interesting question here is not how to build a more powerful animation system. It mirrors the layout question: if animation is a separate concern, it can have a separate declarative home in the markup. Instead of stacking animation utilities next to everything else:
<div
class="
card
animate-in
fade-in
slide-in-from-bottom-4
duration-500
"
>
you describe the animation separately:
<div
class="card"
and-motion="slide-in-up"
and-motion-duration="500ms"
>
And when the trigger matters, you state it explicitly along with delay and duration:
<div
class="card"
and-motion="slide-in-up"
and-motion-trigger="enter"
and-motion-duration="800ms"
and-motion-delay="200ms"
>
The markup declares which animation runs, when it runs and how it is timed; the implementation handles the rest. For an enter trigger that likely means an IntersectionObserver watching the element. Hover and tap triggers use their own listeners. Crucially, the prefers-reduced-motion media query can be honored in one central place, instead of relying on every component author to remember it.
Attributes are not magic. What they offer is a way for an element to announce a capability without pushing it into the framework's component tree.
Declarative until declarative stops helping
This approach has a ceiling. A simple entrance animation fits perfectly into a single attribute:
and-motion="fade-in"
A modal's exit transition is a different kind of problem. Suppose the modal must animate out and be removed from the DOM only after the animation has finished. Now lifecycle and ordering matter, and a small imperative call expresses the intent more clearly:
await player.play('fade-zoom-out');
closeModal();
Attempting to encode every lifecycle relationship into an ever-growing HTML attribute would make the declarative API worse, not better. The guiding rule is to pick the simplest layer that accurately represents the problem, whether that is plain CSS, an attribute, a state machine, an ordinary framework service or a Web Component. Declarative style should not become dogma. Nobody is trying to get rid of JavaScript or frameworks. The aim is to avoid escalating each problem to the heaviest abstraction available just because it is there.
Attributes as small capability APIs
Once both layout and motion follow this pattern, attributes start to look less like configuration and more like small capability APIs. Consider this fragment:
<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>
It is still a <section>, and its semantic meaning is intact. Getting spacing, presentation and an entrance animation did not require a stack of wrappers like this:
<and-stack>
<and-motion-container>
<and-card>
...
</and-card>
</and-motion-container>
</and-stack>
That nested version is not automatically wrong. If those elements carry meaningful behavior and expose a useful API, they may well deserve to be components. The distinction is narrower: a reusable capability does not automatically have to become one more component. Often the DOM node already exists and only needs layout, or motion, or tooltip behavior. The framework does not always have to know about it, and when an abstraction lives directly on HTML it travels for free to Angular, Astro, React, Vue, or a page with no framework.
Framework-agnostic does not mean framework-free
There is an obvious risk here: "framework-agnostic" can easily turn into another purity contest, which would miss the point entirely. Frameworks earn their place by integrating things. Angular provides signals, templates, dependency injection, forms, routing and a clear application model. React has a very mature composition ecosystem. Vue and Svelte make yet other trade-offs.
Framework-independent behavior still has to be connected to each application's reactive system. That is exactly why Zag ships framework adapters: an adapter binds a machine to a framework's reactivity, lifecycle and DOM conventions, and the machine can stay independent only because another layer understands the framework.
The layered approach described here carries the same costs:
- An Angular component built on a generic state factory may require an
effect()to keep signal inputs in sync with the external state. - A Web Component must connect the state layer to its own lifecycle callbacks.
- Attribute-driven layout introduces a small DSL that every team member has to learn.
- Motion attributes add yet another API surface.
- Splitting everything into separate packages creates contracts that must stay compatible over time.
Sometimes a straightforward framework-specific component is plainly the better design. That is why an Angular-only, copy-in component kit still makes sense alongside all of this. Nobody should send every Angular button through a chain of four adapters, a pair of state machines and a Web Component because "agnostic" sounds sophisticated. That is a very expensive way to avoid writing:
<volt-button>
Framework independence pays off when portability is an actual requirement. When it is not, tight framework integration is often the better answer.
Framework-agnostic UI as a stack, not a library
The phrase "framework-agnostic UI library" usually conjures a set of components that somehow work everywhere. Web Components get fairly close to that for rendered components. A more useful model, though, is not a single universal library but a stack of responsibilities, each becoming framework-specific at a different point:
Application
│
Angular / React / Vue / Astro
│
framework adapters
│
─────────────────────────────────────────
│
headless behavior / state machines
│
Web Components HTML capabilities
│ │
│ layout / motion attributes
│ │
─────────────────────────────────────────
│
Web Platform
No application has to use every layer:
- One project uses a Web Component directly and never touches the headless state beneath it.
- Another uses only the state machine and builds its own Angular UI on top.
- A static Astro page may need nothing beyond the layout and motion attributes.
- An Angular application may ignore the whole stack and use an Angular-native kit with Angular primitives, since that yields the smoothest developer experience.
Being able to mix and match is the whole idea. Framework-agnostic should not mean the framework is forbidden. It means you decide where the framework boundary sits, instead of letting it spread automatically through every reusable layer.
Keeping the framework at the application layer
None of this is an argument against Angular or any other framework. It is an argument about how much responsibility a framework receives by default. Walk through the examples again with that lens:
- A dropdown's visual design may belong to the product, and its rendering to Angular, but its interaction model does not have to.
- A shared Button can be a Web Component when you want the identical Button across several projects, or a custom Angular component that shares only a small behavioral core when a product needs its own visual language.
- A card can be styled with Tailwind and still keep its layout in
and-layout. - An entrance effect may require nothing more than a tiny observer and an attribute, and it should not care whether Astro or Angular produced the HTML.
Seen together, the pieces line up:
- Headless UI strips out the visual opinion.
- State machines free the behavior from any particular renderer.
- Web Components let a finished, rendered component travel between stacks.
- Dedicated attributes anchor lightweight capabilities like layout and motion to the HTML itself instead of to the framework.
None of these ideas is new, and not every UI system should be built this way. Combined, however, they challenge a long-standing default: that the framework must also sit beneath every reusable UI abstraction an application has.
Key takeaways
- Distinguish "unstyled" from "renderer-independent"; most headless libraries are the former only.
- Put interaction logic that several products share into framework-free state machines or factories, and pay the adapter cost consciously.
- Use Web Components when the rendered component itself, not only its behavior, must be identical across stacks.
- Reach for CSS-only attribute capabilities for layout and simple motion, and switch to imperative code as soon as lifecycle ordering matters.
- Keep framework-specific components where portability is not a requirement; the aim is to use frameworks without making every layer depend on them.