Component libraries get evaluated as a single yes-or-no decision far too often. A more useful question separates the visual layer of a component from its interaction layer, then asks who should own the behavior underneath a button versus a combobox versus a dialog. Buttons and cards carry almost no hidden behavior. Dialogs, drawers, and comboboxes carry a great deal of it — focus trapping, keyboard navigation, ARIA state, dismissal rules. This piece lays out a way to decide, component by component, when hand-rolling that behavior is a reasonable bet and when wrapping a mature headless primitive behind your own design system earns its cost.
I recently watched a team answer this exact question twice, in opposite directions, inside a single sprint.
A team that reversed course in three and a half weeks
I was reviewing a frontend mid-port from an older CMS setup, built on Next.js 16 and React 19, with its own hand-rolled component layer already in place: a button, a badge, an accordion, a handful of primitives with no library underneath any of them. Partway through the engagement, the team bulk-installed an entire generated component catalog — more than sixty files, covering everything from tooltips to command palettes, built on top of Base UI, the headless primitive library that grew out of the Radix lineage. The commit message described the intent plainly: install and configure the full suite.
Three and a half weeks later, one commit removed almost all of it. A grep through the history told the real story: the generated dialog, drawer, popover, tooltip, and combobox components had never been imported anywhere in their entire lifespan. Only a button, an accordion, and a badge had ever shipped to a real page. The remaining fifty-plus components sat in the repository as dead weight for the full three and a half weeks, then got deleted along with the generated stylesheet utilities and the dependency itself.
What survived that reversal is more interesting than the reversal.
The one component that genuinely needed hardening was a modal used for a review-submission form and a photo lightbox. An accessibility audit flagged it: no focus trap, no focus restoration, no documented keyboard containment. That gap became a real ticket, and the fix that shipped was hand-rolled, drawing on the same interaction patterns a mature primitive would provide, built directly against the component. It portals to the document body, locks body scroll, traps focus with a ref-based hook that keeps nested dialogs independent of one another, restores focus to the trigger element on close, and sets the ARIA attributes a screen reader needs to announce it correctly. Close to three hundred lines of unit tests now cover focus-on-open, tab confinement, escape-to-close, backdrop dismissal, and focus restoration across nested instances.
The policy the team wrote afterward captures the underlying framework better than any diagram could. Their own component layer stays the public interface for every feature. A headless primitive library is permitted to sit underneath that layer, reserved for cases where the accessibility-sensitive interaction machinery — a focus trap, a listbox, a date picker — would be genuinely hard to get right by hand. The generated, pre-styled component catalog itself got ruled out entirely, because its bundled styling actively conflicted with their existing markup and CSS.
Not every component carries the same risk
A card is mostly markup, styling, responsive behavior, and a handful of props. A dialog can involve focus trapping, initial focus placement, focus restoration, escape handling, outside-click dismissal, scroll locking, portals, nested overlays, keyboard behavior, ARIA relationships, and screen-reader expectations, all at once. A combobox adds listbox semantics, active-option tracking, arrow-key navigation, filtered results, single or multiple selection, and keyboard and pointer interaction working together correctly.
You can build any of this yourself. The useful question is whether the hidden behavior in a given component justifies committing to own every edge case of it for the lifetime of the product.
What a primitive library actually buys you
The value in a headless primitive library sits in interaction behavior that other people have already spent significant time getting right: focus management, keyboard navigation, ARIA state, dismissal logic, roving focus, selection models, nested overlays, and the cross-browser edge cases that only show up under real use. Adopting one is a bet that a very good reason exists to own every edge case of that behavior going forward. In the story above, the team decided that bet was worth making for exactly one interaction pattern: the accessible dialog. The other fifty-plus components they had installed speculatively never got that same investment.
Keep the visual design yours
Adopting a mature primitive for behavior can happen while keeping the entire visual system your own. A local component can wrap an established interaction primitive underneath while exposing its own API on top:
The feature code still calls <Modal size="lg" tone="dark" />. Your component owns styling, tokens, animation, sizing, tone, composition, and naming. The primitive underneath owns the difficult interaction mechanics. That separation is what let the team in the example keep their existing markup and CSS classes while still hardening one component's accessibility behavior.
Keep the library behind your own layer
A strong architectural rule follows from that separation: feature code should never import a third-party primitive directly. It should import your own wrapper.
text
Feature code
↓
Local UI layer
↓
Primitive library
A feature only needs to know that it has a modal to render. Which low-level library implements that modal underneath stays an implementation detail feature code never references. The team's written policy makes this explicit: their prefixed component layer is the only thing a feature is allowed to import, and a primitive library — if used at all — sits behind it, never imported directly from feature code. That gives the application a stable internal API, and it means a future swap of the underlying library stays contained to that one wrapper file.
Native HTML still wins when it covers the case
None of this argues for wrapping every native element in another abstraction. A normal button should generally still be a <button>. A text field should still be an <input>. A simple disclosure is often fine as plain semantic HTML. Every wrapper adds an API surface, a dependency, and more code to understand, so the working question for any given component is whether its interaction complexity is high enough that delegating the behavior returns real value.
Sizing up the risk by component type
The case for a mature primitive gets stronger as focus behavior increases, keyboard behavior increases, ARIA state grows more complex, and overlays start nesting. That is the threshold worth watching.
When hand-rolling holds up
Owning a component yourself works well when the interaction is simple, native HTML covers most of the problem, the team has real accessibility expertise, and behavior tests already exist and pass. The dialog in the story above is a good example on the tested side: close to three hundred lines of unit tests cover exactly the behaviors that matter for that component.
Hand-rolling also carries a specific failure mode worth watching for: partial, silently incomplete ports that compile cleanly and look finished. The same audit that caught the missing focus trap also caught a hand-rolled accordion shipping without a mode prop and an entire rendering path present in the reference design it was ported from — a gap that had passed code review because nothing about it looked broken. A versioned library reduces this particular risk by shipping tested behavior upstream. A component you own entirely yourself has no equivalent maintainer catching that drift for you.
When a mature primitive earns its place
Dialogs are a strong candidate. Comboboxes and multi-selects are an even stronger one. Both look simple in a visual design; the difficult part is almost entirely invisible. A mouse user may never notice a missing focus trap. A keyboard user will. A screen-reader user will notice sooner. Edge cases in these components tend to surface only once real application composition gets more complex than the first prototype suggested.
Resist adopting everything at once
The failure mode on the other side is just as real, and the story above is a clean example of it: once a primitive library becomes available, it is tempting to reach for all of it immediately. Sixty-plus generated components shipped into that codebase in a single commit, and fifty-plus of them never got imported by a single feature before they were deleted. Adopting a primitive library selectively, driven by an actual feature that needs it, avoids that dead weight entirely.
Harden the primitive you are actually touching
A useful production habit follows directly from that lesson: harden a primitive at the moment a real feature depends on it. The team's dialog got its focus trap and test coverage because an accessibility audit flagged a real gap in a component two live features already depended on. The rest of their component layer stayed untouched, since consistency alone was never a strong enough reason to invest in it.
text
Real feature requirement
↓
Existing local component
↓
Assess interaction risk
↓
Harden the primitive if the risk justifies it
↓
Ship the feature
Let your own API describe the product
Keeping your own component layer above any primitive means your design system gets to speak in terms of your product. <Modal tone="inverse" size="lg" /> or <Combobox label="Products" multiple maxSelections={3} /> describe what your application does. The internal structure of whichever third-party library sits underneath stays invisible to that description. The team's own governing rule states this directly: their prefixed layer is the public interface, and any headless primitive underneath is an implementation detail, reserved for the cases where native HTML genuinely cannot provide the accessible interaction on its own.
Behavior and styling are separate decisions
The same codebase makes this point almost by accident. Its styling approach still runs on plain global CSS classes, with an explicit, documented plan to migrate to CSS Modules incrementally. None of that affects the dialog's interaction behavior at all — the focus trap, the ARIA attributes, and the dismissal logic work the same regardless of which styling system sits on top. A component's behavior and its presentation can be decided independently. Adopting a primitive for the behavior leaves the presentation entirely open.
A short decision checklist
Does native HTML already solve this well? Start there if it does.
How much invisible behavior does the component carry — focus, keyboard, ARIA state, dismissal logic?
What would an accessibility mistake cost here, in user impact and in how expensive the fix would be to catch late?
Is an existing hand-rolled version already tested and stable? Replacing something that already works well adds risk without adding value.
Can a primitive library sit fully behind your own component API, so feature code only ever imports your own layer?
Does a real feature need this now, or would adopting a primitive here be speculative?
That last question is the one the story above answers most clearly.
FAQ
Does this mean generated component catalogs like shadcn's are a poor choice?
They work well when a team is starting fresh and wants to adopt a visual language wholesale. They create friction when a product already has its own design system, since every generated component then needs restyling, replacement, or removal — which is exactly what happened in the case above.
Do I need behavior tests before hand-rolling a dialog?
Treat automated tests covering focus-on-open, tab confinement, escape handling, and focus restoration as the entry price for maintaining a hand-rolled dialog long-term. The team in this example wrote close to three hundred lines of tests for exactly that component before considering it done.
What about a simple dropdown that doesn't need full combobox behavior?
A native <select> or a small custom listbox handles a simple dropdown without touching a primitive library at all. The decision gets harder once you add search filtering, multi-selection, or asynchronous options — that is where the hidden behavior grows fastest.
How do I know if a hand-rolled component has drifted from what it should do?
Audit it explicitly against whatever reference design or spec it was built from, line by line if needed. The same team caught a hand-rolled accordion that had silently shipped without a mode prop and an entire rendering path present in the reference — a gap that compiled cleanly and looked finished until someone checked.
Closing
Track ownership of interaction behavior on a per-component basis, driven by what a real feature actually needs. That one variable settles the custom-versus-library question more reliably than any blanket policy toward adopting or avoiding a library altogether. A card, a button, and a badge rarely need anyone else's help. A dialog, a drawer, and a combobox usually do.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks,
Matija
What this is based on: A real design-system port for a client project, reviewed and anonymized for this article. Stack: Next.js 16.3.3, React 19.2.8, TypeScript, Tailwind CSS 4.3, Base UI, shadcn-generated components. Details on ticket numbers, component naming, and internal doc references have been generalized or removed. Last updated: September 2026.
SEO metadata
Title: How Much UI Interaction Behavior Should You Own?
Meta description: A framework for deciding when to hand-roll UI primitives like dialogs and comboboxes versus wrapping a headless library like Base UI or Radix behind your own component layer.
Suggested keywords: headless UI primitives, Base UI, Radix, custom dialog accessibility, combobox accessibility, design system component architecture, shadcn