Styling radio button with Tailwind

React Radio button component (styled) from scratch

Marek Rozmus
8 min readMar 23, 2024
Photo by Frederic Christian on Unsplash

Another post after this one about styling with Tailwind. Similar like with the checkbox element we will go step by step to restyle the radio button.

The whole code can be found in this GitHub repository: https://github.com/marekrozmus/blog-styling-react-radio-button-with-tailwind

Styling step by step

This is what we start with:

const Radio = () => (
<div>
<input type="radio" />
<label>This is the radio label</label>
</div>
);

Now add an id so that we can make the label clickable:

const Radio = ({ id }: RadioProps) => (
<div>
<input type="radio" id={id} />
<label htmlFor={id}>This is the radio label</label>
</div>
);

Styling

To remove the default styling use appearance-none class.

<input type="radio" id={id} className="appearance-none" />

The radio will disappear — it needs to be design from scratch.

The radio button

Adding the width, height, some colour of the border and making it round. If we won’t make it round it will look rather like checkbox — a square.

<input type="radio" id={id}
className="
appearance-none
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>

Alignment fixes

It looks a little bit off. Some alignment here and there is needed to make it look ok. A simple fix can be applied to the parent div and we will have something like this:

<div className="flex gap-2 items-center">
<input type="radio" id={id}
className="
appearance-none
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>
<label htmlFor={id}>This is the radio label</label>
</div>

Fixed! Right? Well, that depends. Check what happens if we will have long label and the text wrapping will take place:

First of all our button shrinked, that can be easily fixed by adding shrink-0 to the button styles.

Now the question if we are ok with having the button in the middle or rather on the top. I prefer having it on the top so depends on the font size I add some top margin and align all to top (remove items-center ).

<div className="flex gap-2">
<input type="radio" id={id}
className="
appearance-none shrink-0 mt-1
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>
<label htmlFor={id}>This is the radio label</label>
</div>

Add checked state

The check element needs to be placed inside the circle. So a check element appearance needs to be defined and absolute usage is needed to place it in proper place.

<input type="radio" id={id}
className="
relative
appearance-none shrink-0 mt-1
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>
<div
className="
absolute
w-2 h-2 rounded-full bg-blue-500
"
/>
<label htmlFor={id}>This is the radio label</label>

A simple circle with same colour, added but looks like some more alignment is needed:

Adding some margins and we got it:

<div className="
absolute
w-2 h-2 rounded-full bg-blue-500 ml-1 mt-2"
/>

I am not a fan of using margins to layout something. If we change the button’s width then we would need also to update those margins:

I am not also a fan of using absolute positioning, so maybe there is other way to get rid of that?

The grid placement

Other way is to put both — the button and the check element in one grid cell and use centre alignment. All margins and absolute are not needed anymore.

<div className="grid place-items-center">
<input type="radio" id={id}
className="
col-start-1 row-start-1
appearance-none shrink-0
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>
<div
className="
col-start-1 row-start-1
w-2 h-2 rounded-full bg-blue-500
"
/>
</div>

And if we change the size of the button, the inner circle will still be in the centre.

In this case `w-5 h-5` is used

Alignment problem is back

After checking new implementation in smaller container we got:

Some tweaking — aligning all to top items-start and (unfortunately) using m-1 for moving the circle a little bit lower.

<div className="flex gap-2 items-start">
<div className="grid place-items-center mt-1">
<input type="radio" id={id}
className="
col-start-1 row-start-1
appearance-none shrink-0
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>
<div
className="
col-start-1 row-start-1
w-2 h-2 rounded-full bg-blue-500"
/>
</div>
<label htmlFor={id}
className="text-start">This is the radio label</label>
</div>

The selected and unselected states

To go further we need some changes in the component — add label and defaultChecked . Just showing the places with changes here. For the full code check the repository.

const Radio = ({ defaultChecked, id, label }: RadioProps) => (
...
<input type="radio" id={id} defaultChecked={defaultChecked}
...
<label htmlFor={id} className="text-start">{label}</label>

And some component rendering:

<Radio id='radio1' label='Radio 1' defaultChecked />
<Radio id='radio2' label='Radio 2' defaultChecked={false} />

This is not what should be rendered — the second one should not be selected. The problem is that we are missing styling for the unselected state.

The inner circle should be only visible when the checked state is true. So the background colour needs to base on the check state of the input element. This can be done by setting peer class on the input and then adding peer-checked: prefix to the background colour:

<input type="radio" id={id} defaultChecked={defaultChecked}
className="
peer
col-start-1 row-start-1
appearance-none shrink-0
w-4 h-4 border-2 border-blue-500 rounded-full
"
/>
<div
className="
col-start-1 row-start-1
w-2 h-2 rounded-full peer-checked:bg-blue-500
"
/>

The unchecked button is not clickable

Try to click on the circle of the second radio to select it — nothing happens. Now try to click on the Radio 2 label — it works! What is the problem?
The inner circle that we are rendering prevents click events. To fix it one class needs to be added to inner circle: pointer-events-none . This will tell the element to not grab any click events but to just pass them further.

<div
className="
pointer-events-none
col-start-1 row-start-1
w-2 h-2 rounded-full peer-checked:bg-blue-500
"
/>

The disabled state

Lets add two more radio buttons:

<Radio id='radio1' label='Radio 1' defaultChecked />
<Radio id='radio2' label='Radio 2' defaultChecked={false} />
<Radio id='radio3' label='Radio 3 (disabled)' defaultChecked disabled />
<Radio id='radio4' label='Radio 4 (disabled)' defaultChecked={false} disabled />

And update the component:

const Radio = ({ defaultChecked, disabled, id, label }: RadioProps) => (
...
<input type="radio" id={id} defaultChecked={defaultChecked}
disabled={disabled}
...

The disabled Radio 4 (disabled) cannot be selected — so looks like it works correctly. But the appearance needs to be updated so that user see that the item is disabled. In the inner circle the peer-checked and peer-disabled need to be used.

<input type="radio" id={id} defaultChecked={defaultChecked} disabled={disabled}
className="
peer
col-start-1 row-start-1
appearance-none shrink-0
w-4 h-4 border-2 border-blue-500 rounded-full
disabled:border-gray-400
"
/>
<div
className="
pointer-events-none
col-start-1 row-start-1
w-2 h-2 rounded-full peer-checked:bg-blue-500
peer-checked:peer-disabled:bg-gray-400
"
/>

But I got disabled in props

Yup, and you can also use the clsx or classnames package and have something like this:

<div
className={cx(
"pointer-events-none",
"col-start-1 row-start-1",
"w-2 h-2 rounded-full peer-checked:bg-blue-500",
// "peer-checked:peer-disabled:bg-gray-400"
{
"!bg-gray-400": disabled,
}
)}
/>

But in this case the ! important needs to be used. So use whatever you like.

One thing that to style also “disabled” label we can use only the prop. It is because the label is not a sibling of the input in this nodes structure so the tailwind peer cannot be used there.

<label htmlFor={id}
className={cx(
"text-start",
{
"text-gray-400": disabled,
})}
>
{label}
</label>

The focused state

So far there is no focus state, so if the element is clicked and focused it is not visible. You can use Tab key to go around the page and get something like this:

This can be changed by adding the focus classes:

<input type="radio" id={id} defaultChecked={defaultChecked} disabled={disabled}
className="
peer
col-start-1 row-start-1
appearance-none shrink-0
w-4 h-4 border-2 border-blue-500 rounded-full
focus:outline-none focus:ring-offset-0 focus:ring-2 focus:ring-blue-400
disabled:border-gray-400
"
/>

Now the outline will be visible if you click the item (focus it):

You are still here? check the repository as there is also a full radio group component example with all that was presented here.

Do you like my content? You can support me and buy me a coffee. Thank you so much!

--

--

Marek Rozmus
Marek Rozmus

Written by Marek Rozmus

Senior Frontend Engineer at GoStudent

Responses (2)