Rethinking our frontend future at Spatie
In our post on how we structure the frontend of a Laravel Inertia React application, we described how shadcn/ui is our go-to starting point for new projects. It gets us from zero to shipping fast, and we gradually migrate components into our own common folder as a project matures. This allows us to customize and adapt to each project's needs as we go.
That approach has served us well. But the web has been quietly catching up to (some) of the problems that made us reach for libraries like shadcn and Radix in the first place. Native dialogs, popovers, anchor positioning, customizable selects, etc. have all landed or are landing in browsers right now. Many of them cover a lot of similar things that the Radix primitives sitting in our shadcn directory handle.
This may have us rethinking our frontend setup in the near future. Not abandoning shadcn overnight, but asking a more fundamental question: 'Could we start building our own base components that lean on the base HTML/CSS features instead of a library?'. Components that work regardless of framework, ship less JavaScript, and get accessibility right because the browser handles it rather than us? Can we improve the quality, DX or the impact for our projects without having to compromise?
This post walks through what changed, what is now possible, and where we think we could be heading in the future.
Where we came from: shadcn and Radix as our foundation
For the past few years, every new Spatie project started the same way. We would scaffold the app, install shadcn/ui, and immediately have access to a solid set of components: dialogs, dropdowns, selects, tooltips, popovers. All built on Radix primitives, all reasonably accessible out of the box.
As we mentioned in our structure post, the API that shadcn and Radix expose is sometimes too low-level for application code, so we wrap them in our own abstractions. A shadcn Select with its SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectLabel, and SelectItem becomes a single Select component with an options prop. That is fine and it works, but it means we are maintaining a wrapper around a wrapper around what the browser should arguably handle natively.
The real cost only shows up over time. Radix ships JavaScript for focus trapping, keyboard navigation, scroll locking, outside-click detection, and positioning. Individually these are well-solved problems. Collectively they add up to a meaningful amount of code running in the browser to replicate behavior that HTML has been slowly learning to do on its own. Furthermore with Radix not receiving any more updates. The issues that come with it keep needing the same fixes, requiring more and more code with every component.
The current situation: browsers catching up
The dialog element: modals without the machinery
Building a modal used to require a surprising amount of 'plumbing'. You needed a portal to render outside the DOM tree, a focus trap to keep keyboard navigation inside the modal, scroll lock on the body, Keyboard navigation handling, and a backdrop overlay. That heavy lifting was done by Radix, which is what made us go for that in the past.
The native dialog element, which has been baseline since 2022, does all of this too. Calling showModal() promotes the dialog to the top layer, makes the rest of the page inert, traps focus automatically, handles Escape to close and provides a ::backdrop pseudo-element allowing us to customise the overlay. That is the entire feature set of a modal library, built into the browser with zero JavaScript.
We are not arguing that the native dialog covers every edge case Radix handles at this point in time. Complex nested modals or animated transitions can still benefit from a library. But for confirmation prompts, form dialogs, and information overlays that make up the majority of our modal/dialog usage, dialog does the job with less code and less overhead. This could mean that for the more basic use cases, we could start implementing a component without the need for a whole lot of JavaScript.
The Popover API: dropdowns, tooltips, and menus go native
The Popover API reached Baseline in early 2025. While that is not ideal when we need to support older browser versions, it could become one of the bigger shifts in how we think about overlay UI's in the (near) future. The Popover API handles the core behavior of nearly every dropdown, tooltip, and action menu we build without needing extra heavy lifting.
The API is declarative. A button with a popovertarget attribute and a div with a popover attribute are all you need:
<button popovertarget="actions">Actions</button>
<div id="actions" popover>
<!-- content -->
</div>
The browser handles the top layer promotion (meaning no more z-index fights for us), it handles light dismissals (clicking outside the dialog, closes said dialog) and it provides both escape key support and basic focus management.
There are three popover modes we think are worth knowing about right now. popover="auto" gives you light dismiss and auto-closes other popovers, which is what you want for menus and dropdowns. popover="manual" gives you full control for things like toast notifications that should persist. And popover="hint", which landed in Chrome 133, creates a separate stacking context for ephemeral UI like tooltips, so a tooltip can appear without closing an open menu. That last one alone solves a layering problem that has caused us headaches in multiple projects.
Invoker Commands: interactions without event listeners
The Invoker Commands API introduces two HTML attributes, commandfor and command, that let buttons control other elements. No JavaScript required for the most common interactions:
<button commandfor="confirm" command="show-modal">Delete item</button>
<dialog id="confirm">
<p>Are you sure?</p>
<button commandfor="confirm" command="close">Cancel</button>
</dialog>
This is the simplest version of a native dialog with trigger where a button opens a dialog using the command and commandfor attributes. The list of existing values you can give to the command attribute is limited for now, but with custom values being possible (using the -- syntax) it becomes a great way to create our own interaction logic.
The Invoker Commands API shipped in Chrome and Edge 135, is in Safari Technology Preview, and is behind a flag in Firefox Nightly. It is not something we would ship to production across all browsers today, but the direction is clear: the platform is making "click a button, something happens to another element" a first-class HTML concept. That is precisely the kind of wiring that React and Radix handle for us now.
The future: What else can we look forward too
Interest Invokers: hover-triggered UI, no JavaScript
A pattern we use a lot in current projects, is showing additional information on hover. Profile previews, tooltips on icons and buttons, quick actions on cards. Today this means mouseenter and mouseleave event listeners, timers to avoid flickering, and careful handling of the gap between the trigger and the popover. As of writing this post, that is being handled by Radix/shadcn components in our codebase.
One thing I am personally looking forward too, is the Interest Invoker API (currently experimental in Chrome 139). It introduces the interestfor attribute. Combined with popover="hint", it makes hover-triggered popovers entirely declarative:
<a href="/profile/john" interestfor="profile-preview">John Doe</a>
<div id="profile-preview" popover="hint">
<!-- profile card content -->
</div>
The browser manages hover delays, transitions and it supports long-press on touch devices. It also fires interest and loseinterest events for any custom behavior we may want tied to a hover element. The delay timings are even configurable via CSS with interest-delay-start and interest-delay-end. Event though it is too early to use in real projects and not ready for production, it does signal where we might be going in the near future. The tooltip components/libraries you reach for today may not be needed anymore next month.
CSS Anchor Positioning: goodbye, Floating UI
Every tooltip, dropdown, and popover we build needs to be positioned relative to its trigger. That positioning logic, handling overflow, flipping when there is not enough space, staying anchored during scroll, is the entire reason libraries like Floating UI, Radix and Shadcn exist. We use it (via Radix) in nearly every overlay component, knowing it would have been a complete hassle to build it ourselves from scratch.
That is where the anchors come into play. It works a bit like this:
.trigger {
anchor-name: --menu-trigger;
}
.menu {
position: absolute;
position-anchor: --menu-trigger;
top: anchor(bottom);
left: anchor(left);
position-try-fallbacks: flip-block, flip-inline;
}
The position-try-fallbacks property handles the behavior that Floating UI used to handle. It makes the browser evaluate each fallback position and picks the first one that fits. All without any JavaScript measuring or repositioning needed on our end.
Anchor Positioning is available in Chrome and Safari, with Firefox close behind, at the time of writing. When combined with the Popover API, popovers automatically anchor to their triggering element, which means the most common use case requires no explicit anchor setup at all. We're not going to implement it right away, since it is only expected to become baseline somewhere in 2026, but it is a fantastic feature that could reduce unnecessary bulk for us in the long run.
Customizable select: the end of custom dropdown components
If there is one element that has driven more developers to JavaScript libraries than any other, it is select. The native element was almost impossible to style and in general a big headache for any developer to get right. Chrome 135 introduced appearance: base-select, which opts a native select into a fully customizable state:
select,
::picker(select) {
appearance: base-select;
}
This unlocks styling of the button, the dropdown picker via ::picker(select), individual options (including rich content like icons and images), and the selected value display via <selectedcontent>. The dropdown renders as a popover in the top layer, is positioned with CSS Anchor Positioning, and keeps all native keyboard navigation and accessibility behavior intact. No JavaScript, no ARIA, no custom keyboard handling. Please refer to this post and the mdn docs for more details and updates on this feature.
What all of these new things mean for how we build and scaffold applications
Let's be practical about this. We are not ripping shadcn out of our existing projects, nor are we going to stop using it in the following weeks. Several of these APIs don't have the (browser) support we are looking for just yet and libraries like BaseUI, shadcn and Radix remain the fastest ways to get a polished set of components up and running without needing to spend a lot of time on them.
That said, it is a great time to start thinking about what belongs in our common folder, what needs a library and how much JavaScript we need to ship that could be handled in a better way. Instead of wrapping Radix primitives or Base-UI components, we could start building our own components that don't require libraries to progressively enhance where needed later on. A Dialog component that uses the native dialog element with showModal(). A Popover that uses the popover attribute and popovertarget. A Select that uses appearance: base-select in supporting browsers and falls back gracefully, along with so much other potential things to build. We do however, need to factor in the cost of these self-built components and the running costs of keeping them up to date whenever a spec changes or gets updated. It could potentially be a lot of work in the long run, but it could very well be worth it.
These components would not be tied to React or any framework. The HTML and CSS do the heavy lifting, and the React component is a thin layer on top. That could be a meaningful shift from our current approach, where the Radix primitive is the component and React is essential to its functioning. When the browser handles focus trapping, keyboard navigation, Escape dismissal, and ARIA relationships natively, we do not need to get those things right ourselves. We do not need to test them. They just work, because every major browser developer is investing in getting them right across all platforms. That is a better guarantee than any library can offer. Of course that is only the case once all the new features and specs have reached baseline so that they have a consistent behaviour in most browsers.
The road ahead
The different specs (HTML, CSS, ...) are moving faster than it has in years. The Popover API, dialog, and Invoker Commands are available now. CSS Anchor Positioning is nearly there. Customizable select and Interest Invokers are on the horizon. Each of these directly replaces JavaScript that we currently ship.
At Spatie, we are starting to explore what a set of platform-native base components could look like. Not as a published library, but as the foundation of our common folder: small, framework-light components that get the basics right by leaning on the browser.
Shadcn, Radix and Base-UI gave us a great starting point when the platform could not do what we needed. The platform is slowly catching up. It might be time to meet it halfway. Let's see what the future holds.