These days, the default approach to building a UI is to start with ShadCN, Radix, Ark UI, or something similar, and then customize it into “your” design system. It is not even a conscious choice anymore. When you build with AI, this is usually the first solution it reaches for. The decision is often made for you before you even think about it.
To be fair, these are not bad solutions. Unlike older component libraries such as Material UI or Bootstrap, they are extremely flexible. But they still flood your application with a ton of dependencies. Modals require focus trapping libraries. Tooltips and dropdowns need positioning engines like Floating UI. Your “custom” component library is really just a wrapper around a dozen npm packages.
I do not know about you, but I sigh every time I feel forced to add yet another 20 dependencies. It feels like I am actively building something low quality, messy, and bloated. Which, let us be honest, is true to a certain degree.
I have spent a lot of time looking for a lightweight component library that does not itself depend on half of npm. I have not found one. It starts to feel like filling up your node_modules is a requirement to build a good-looking UI.
Or is it?
While building this personal website over the past couple of months, I discovered something encouraging: modern browsers have quietly solved many of the problems that used to require thousands of lines of library code. In this article, I walk through how to build common UI components using many of the browser’s built-in APIs that a lot of people might not be aware of.
Why This Matters
Libraries like Radix, Headless UI, Floating UI, and Ark UI are excellent. They handle edge cases, accessibility, cross-browser quirks, and a bunch of details you will probably overlook when building UI from scratch. But here is the thing: a lot of that work predates the primitives modern browsers ship today. Components that used to be genuinely painful (or borderline impossible) without libraries are now surprisingly straightforward with built-in APIs.
When browsers solve a problem natively, the solution tends to be faster, smaller, and more reliable than any JavaScript implementation. Your bundle size drops. You avoid version conflicts and breaking changes from upstream. There are fewer abstractions to debug. Performance improves since native APIs are optimized at the engine level.
The Dialog Element
The <dialog> element is the poster child for this shift. Before it existed, building a modal meant:
- Creating overlay markup
- Trapping focus inside the modal
- Handling escape key presses
- Preventing background scroll
- Managing z-index stacking
Now the browser handles most of this automatically.
Basic Modal
Here is a modal in under 30 lines:
<button id="open-btn">Open Modal</button>
<dialog id="modal">
<h2>Modal Title</h2>
<p>Press Escape to close, or use the button below.</p>
<form method="dialog">
<button type="submit">Close</button>
</form>
</dialog>
<style>
dialog::backdrop {
background: rgb(0 0 0 / 50%);
}
</style>
<script>
const openBtn = document.getElementById('open-btn');
const modal = document.getElementById('modal');
openBtn.addEventListener('click', () => modal.showModal());
</script> The showModal() method opens the dialog as a modal, which means:
- It appears on the top layer (above everything, no z-index battles)
- Focus is trapped inside automatically
- The Escape key closes it by default
- A
::backdroppseudo-element is rendered behind it
Compare this to the hundreds of lines you would need to replicate this behavior manually.
Drawer Pattern
Dialogs are not limited to centered modals. With some CSS, you can create slide-in drawers:
<button id="open-btn">Open Drawer</button>
<dialog id="drawer">
<p>Drawer content</p>
<form method="dialog">
<button type="submit">Close</button>
</form>
</dialog>
<style>
#drawer {
margin: 0;
position: fixed;
inset: 0 auto 0 0;
width: 280px;
height: 100dvh;
border: none;
padding: 24px;
box-shadow: 4px 0 24px rgb(0 0 0 / 20%);
transform: translateX(-100%);
transition:
transform 300ms ease-out,
display 300ms ease-out allow-discrete;
}
#drawer[open] {
transform: translateX(0);
}
@starting-style {
#drawer[open] {
transform: translateX(-100%);
}
}
</style>
<script>
const openBtn = document.getElementById('open-btn');
const drawer = document.getElementById('drawer');
openBtn.addEventListener('click', () => drawer.showModal());
</script> The @starting-style rule defines the initial state for the opening animation, while allow-discrete enables smooth transitions for the display property. The same dialog element, styled differently, becomes a navigation drawer with the accessibility features intact.
Closing on Backdrop Click
One thing the browser does not do automatically is close the dialog when clicking the backdrop. Here is how to add that:
const modal = document.getElementById('modal');
modal.addEventListener('click', (event) => {
if (event.target === modal) {
modal.close();
}
}); This works because clicking the backdrop actually fires a click event on the dialog element itself. If the click target is the dialog (not its children), we know the backdrop was clicked.
You can see a more complete implementation in my Dialog component, which adds smooth transitions and scroll locking.
The Popover API
The Popover API landed in browsers in 2024 and solves a different but equally painful part of floating UI: reliable open/close behavior and dismissing when the user clicks outside. Dropdowns, tooltips, and menus used to require libraries like Floating UI to handle:
- Light dismiss (clicking outside to close)
- Managing open/close state
The Popover API handles light dismiss and basic open/close behavior natively. For simple menus and tooltips, that is often enough.
Basic Dropdown
<button popovertarget="dropdown">Options</button>
<div id="dropdown" popover>
<button>Edit</button>
<button>Delete</button>
<button>Share</button>
</div> That is it. No JavaScript required for basic functionality. The popovertarget attribute links the button to the popover, and clicking the button toggles it open and closed. Clicking anywhere outside closes it automatically.
Popover Positioning
The Popover API does not automatically position the popover near its trigger. For that, you have two options:
- Use CSS Anchor Positioning (newest approach, still gaining browser support)
- Calculate position with JavaScript
Here is the JavaScript approach that works today:
<button id="trigger">Hover me</button>
<div id="tooltip" popover="manual">This is a tooltip</div>
<style>
#tooltip {
position: fixed;
margin: 0;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: #1a1a1a;
color: white;
font-size: 14px;
}
</style>
<script>
const trigger = document.getElementById('trigger');
const tooltip = document.getElementById('tooltip');
trigger.addEventListener('mouseenter', () => {
const rect = trigger.getBoundingClientRect();
tooltip.style.top = `${rect.bottom + 8}px`;
tooltip.style.left = `${rect.left}px`;
tooltip.showPopover();
});
trigger.addEventListener('mouseleave', () => {
tooltip.hidePopover();
});
</script> Notice the popover="manual" attribute. This tells the browser we want to control show/hide ourselves instead of using the default toggle behavior.
For a complete implementation with collision detection and alignment options, check out my Popover component.
When to Still Use Libraries
I am not saying you should never use component libraries. There are cases where they still make sense:
- Complex positioning with multiple fallback positions and nested popovers
- Data grids with sorting, filtering, and virtualization
- Virtual scrolling for lists with thousands of items
- Drag and drop with complex constraints
- Rich text editing
The point is to recognize when native APIs are sufficient. A tooltip that appears below a button does not need Floating UI. A modal dialog does not need Radix. An FAQ accordion does not need Headless UI.
What’s Next on the Platform
Browser APIs keep getting better, and two upcoming improvements are going to make “dependency-free components” even more realistic.
First, CSS Anchor Positioning is turning “place this tooltip next to that button” into a CSS problem. Instead of measuring DOM rects and writing collision logic, you can mark an element as an anchor and position another element relative to it in pure CSS, with built-in fallbacks when space runs out. This is already available in Chromium-based browsers, while Firefox does not support it yet and Safari support is still rolling out.
Second, the web platform is finally getting a properly customizable <select>. Chrome has shipped an opt-in mode (appearance: base-select) that exposes the picker part of a <select> for styling (via ::picker(select) and related hooks), which means you can build a good-looking select without replacing it with a div soup and a pile of accessibility work. Other browsers still fall back to the classic native select UI for now, but the direction is clear.
The trend is clear: browsers are absorbing functionality that used to require libraries. By building on native APIs now, you position your codebase to benefit from future improvements automatically.
Conclusion
The component library landscape has changed. Problems that required external dependencies five years ago now have native solutions. The <dialog> element gives you accessible modals with focus trapping built in. The Popover API gives you light dismiss and native toggling for basic overlays.
This does not mean every project should abandon existing libraries immediately. But for new projects, or when adding individual components, consider whether the platform already provides what you need.