LmCast :: Stay tuned in

The Overcomplexity of the Shadcn Radio Button

Recorded: Jan. 20, 2026, 10:03 a.m.

Original Summarized

The Incredible Overcomplexity of the Shadcn Radio Button

Paul Hebert
Writing
Work
Contact

GitHub

The Incredible Overcomplexity of the Shadcn Radio Button
The other day I was asked to update the visual design of radio buttons in a web
app at work. I figured it couldn't be that complicated. It's just a radio button
right?
<input type="radio" name="beverage" value="coffee" />
Boom! Done. Radio buttons are a built-in HTML element. They've been around for
30 years. The browser makes it easy. Time for a coffee.
Enter Shadcn
I dug into our codebase and realized we were using two React components from
Shadcn to power our radio buttons: <RadioGroup> and
<RadioGroupItem>.
For those unfamiliar with Shadcn, it's a UI framework that provides a bunch of
prebuilt UI components for use in your websites. Unlike traditional UI
frameworks like Bootstrap, you don't import it with a script tag or
npm install. Instead you run a command that copies the components into your
codebase.
Here's the code that was exported from Shadcn into our project:
"use client";

import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";

import { cn } from "@/lib/utils";

function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}

function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}

export { RadioGroup, RadioGroupItem };
Woof... 3 imports and 45 lines of code. And it's importing a third party icon
library just to render a circle. (Who needs CSS border-radius or the SVG
<circle> element when you can add a third party dependency instead?)
All of the styling is done by the 30 different Tailwind classes in the markup. I
should probably just tweak those to fix the styling issues.
But now I'm distracted, annoyed, and curious. Where's the actual <input>?
What's the point of all this? Let's dig a little deeper.
Enter Radix
The Shadcn components import components from another library called Radix. For
those unfamiliar with Radix, it's a UI framework that provides a bunch of
prebuilt UI components...
Wait a second! Isn't that what I just said about Shadcn? What gives? Why do we
need both? Let's see what the Radix docs say:

Radix Primitives is a low-level UI component library with a focus on
accessibility, customization and developer experience. You can use these
components either as the base layer of your design system, or adopt them
incrementally.

So Radix provides unstyled components, and then Shadcn adds styles on top of
that. How does Radix work? You can see for yourself on GitHub:
https://github.com/radix-ui/...
This is getting even more complicated: 215 lines of React code importing 7 other
files. But what does it actually do?
Taking a look in the browser
Let's look in the browser dev tools to see if we can tell what's going on.

Okay, instead of a radio input it's rendering a button with an SVG circle inside
it? Weird.
It's also using
ARIA attributes
to tell screen readers and other assistive tools that the button is actually a
radio button.
ARIA attributes allow you to change the semantic meaning of HTML elements. For
example, you can say that a button is actually a radio button. (If you wanted to
do that for some strange reason.)
Interestingly, here's the
First Rule of ARIA use:

If you can use a native HTML element or attribute with the semantics and
behavior you require already built in, instead of re-purposing an element
and adding an ARIA role, state or property to make it accessible, then do
so.

Despite that, Radix is repurposing an element and adding an ARIA role instead of
using a native HTML element.
Finally, the component also includes a hidden <input type="radio"> but only if
it's used inside of a <form> element. Weird!
This is getting pretty complicated to just render a radio button. Why would you
want to do this?
Styling radio buttons is hard (Wait, is it?)
My best guess is that Radix rebuilds the radio button from scratch in order to
make it easier to style. Radio buttons used to be difficult to style
consistently across browsers. But for several years we've been able to style
radio buttons however we want using a few CSS tools:

appearance: none removes the radio button's default styling allowing us to
do whatever we want.
We can use the ::before pseudo-element to render a "dot" inside of the
unstyled radio button.
We can use the :checked pseudo-class to show and hide that dot depending on
whether the radio button is checked.
border-radius: 50% makes things round.

Here's an example implementation:
input[type="radio"] {
/* Disable the browser's default radio button styles */
appearance: none;
margin: 0;

/* Recreate the circle container */
border: 1px solid black;
background: white;
border-radius: 50%;

/* Center our dot in the container */
display: inline-grid;
place-content: center;

/* Use a pseudo-element to display our "dot" */
&::before {
content: "";
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
}

/* And display it when the radio button is checked */
&:checked::before {
background: black;
}
}
This doesn't require any dependencies, JavaScript, or ARIA roles. It's just an
input element with some styles. (You can do the same thing with Tailwind if
that's your jam.)
It does require knowledge of CSS but this isn't some arcane secret.
Googling "how to style a radio button"
shows several blog posts explaining these techniques. You may say this is a lot
of CSS, but the Shadcn component we were using had 30 Tailwind classes!
I'm not trying to convince you to write your own component styles
Look, I get it. You've got a lot going on. You're not big on CSS. You just want
to grab some prebuilt components so you can focus on the actual problem you're
solving.
I totally understand why people reach for component libraries like Shadcn and I
don't blame them at all. But I wish these component libraries would keep things
simple and reuse the built-in browser elements where possible.
Who cares?
Web development is hard. There's inherent complexity in building quality sites
that solve problems and work well across a wide range of devices and browsers.
But some things don't have to be hard. Browsers make things like radio buttons
easy. Let's not overcomplicate it.
To understand how our radio buttons work I need to understand two separate
component libraries and hundreds of lines of React.
Website visitors need to wait for JavaScript to load, parse, and run in order to
be able to toggle a radio button. (In my testing, just adding these components
added several KB of JS to a basic app.)
It's just a radio button
Why am I making such a big deal out of this? It's just a radio button.
But these small decisions add up to more complexity, more cognitive load, more
bugs, and worse website performance.
We have strayed so far from the light
Look at it. It's beautiful:
<input type="radio" name="beverage" value="coffee" />

Fancy a game?
Play my free daily word puzzle, Tiled Words!

Paul Hebert’s article critiques the excessive complexity of implementing radio buttons using the Shadcn UI framework, contrasting it with the simplicity of native HTML elements. He begins by recounting his initial assumption that updating radio buttons would be straightforward, relying on the basic `<input type="radio">` element. However, upon examining his workplace’s codebase, he discovered that the team had adopted Shadcn components—specifically `<RadioGroup>` and `<RadioGroupItem>`—which are built on top of Radix UI, another library. This layered approach led to a convoluted implementation involving multiple dependencies, custom styling, and accessibility considerations that Hebert argues unnecessarily complicate a seemingly simple task.

The Shadcn components, as presented in the article, require importing three external libraries: `@radix-ui/react-radio-group`, `lucide-react` for icons, and a custom utility file (`cn`) for styling. The code itself spans 45 lines, with extensive Tailwind CSS classes managing visual properties such as borders, shadows, and transitions. Hebert highlights the inclusion of a third-party icon library to render a circle, which he finds perplexing given that native CSS solutions like `border-radius` or SVG elements could achieve the same result without external dependencies. He questions the rationale for such a design decision, suggesting that it introduces unnecessary overhead and obscures the underlying functionality.

A deeper exploration reveals that Shadcn’s radio button components are built using Radix UI, a framework that provides low-level, unstyled components. Radix itself is described as a library that prioritizes accessibility and customization but requires significant boilerplate code. Hebert notes that Radix’s `RadioGroupPrimitive` component, which underpins Shadcn’s implementation, involves 215 lines of React code and imports seven additional files. This complexity is further compounded by the use of ARIA (Accessible Rich Internet Applications) attributes to simulate the behavior of a native radio button. For instance, Radix repurposes a `<button>` element and assigns it an `aria-role` of "radio," effectively tricking assistive technologies into interpreting it as a radio button. While ARIA is designed to enhance accessibility, Hebert warns against its misuse, citing the first rule of ARIA: "If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so." By circumventing native elements, Radix and Shadcn introduce a layer of complexity that undermines the simplicity of HTML’s built-in capabilities.

Hebert also points out a peculiar quirk in Radix’s implementation: the inclusion of a hidden `<input type="radio">` element, which is only rendered if the component is used within a `<form>`. This behavior, while technically functional, further muddies the waters by conflating native and custom implementations. The article argues that this approach forces developers to navigate multiple layers of abstraction—Shadcn, Radix, and the browser’s native HTML—when a straightforward solution would suffice.

The author then shifts focus to the broader debate over styling radio buttons. He challenges the notion that native radio buttons are inherently difficult to style, citing modern CSS techniques such as `appearance: none`, `::before` pseudo-elements, and `:checked` selectors to create custom visual designs. A sample CSS snippet demonstrates how a radio button can be restyled with minimal code, eliminating the need for external libraries or JavaScript. Hebert emphasizes that this approach is not only simpler but also more efficient, as it avoids the performance penalties associated with loading and executing additional JavaScript. He contrasts this with Shadcn’s implementation, which relies on 30 Tailwind classes to manage styling—a stark contrast to the conciseness of native CSS.

Despite acknowledging the appeal of component libraries like Shadcn, Hebert expresses frustration with their tendency to prioritize abstraction over simplicity. He concedes that developers often turn to such tools to streamline workflows and reduce the cognitive load of managing intricate CSS or accessibility considerations. However, he argues that this convenience comes at a cost: increased complexity, higher maintenance overhead, and potential performance issues. The article highlights how the reliance on layered libraries forces developers to grapple with hundreds of lines of code and multiple dependencies, all to replicate functionality that could be achieved with native elements.

Hebert concludes by advocating for a return to simplicity, urging developers to leverage the browser’s built-in capabilities whenever possible. He acknowledges that web development is inherently complex, but contends that certain tasks—like implementing radio buttons—should not require convoluted workarounds. His critique extends beyond Shadcn and Radix, touching on a broader trend in modern web development where abstraction often overshadows practicality. By emphasizing the elegance of native HTML and CSS, he calls for a more mindful approach to tooling that prioritizes clarity, efficiency, and maintainability over unnecessary complexity.

The article serves as a cautionary tale about the trade-offs involved in adopting component libraries, particularly when they obscure the fundamental principles of web development. While tools like Shadcn and Radix offer valuable abstractions, Hebert’s analysis underscores the importance of understanding their underlying mechanics and questioning whether they truly address real problems or simply complicate existing solutions. In an era where performance and accessibility are paramount, his argument for simplicity resonates as a timely reminder that sometimes the most effective solutions are the ones closest to the metal.