Beautiful Radio Buttons with Headless UI and Tailwind CSS
You don’t have to sacrifice accessibility for a custom style!
Introduction
Overriding the design of input or form elements is generally a complex process.
It is often required to completely hide the default input and overlay it with other stylable elements like div
or label
.
Not to mention handling the accessibility and different states.
Headless UI provides a more elegant way of implementing custom input elements.
With Headless UI you only need to worry about styling the custom component with CSS classes and it will take care of the rest. Let’s get started!
Below is what we are trying to build.
Installation
Headless UI requires @headlessui/react
and @headlessui/tailwindcss
to work. The second dependency is optional, we will see how and why it may be helpful later.
We will also be using Tailwind CSS for styling the components. Headless UI provides a Tailwind CSS plugin that makes it easier to style components based on the state. This is optional, plain CSS can also be used.
The above packages are the only setup we need. We can start using the Headless UI components in our code!
Project Structure
We are using a simple React application created using create-react-app
. The only extra file needed here will be tailwind.config.js
that can be set up from the Tailwind CSS docs.
// project structure
public/
index.html
src/
App.js
index.js
package.json
tailwind.config.js (if using tailsind css)
Creating the Radio Buttons
We will import the RadioGroup
component from the HeadlessUI package and use it inside our form component.
Note that the usage of the name
prop is only useful when using the radio group inside a form and the values need to be used on submit.
The value
and onChange
props can be used on RadioGroup
to use it as a controlled component.
RadioGroup.Option
is used to specify the individual radio buttons inside the radio group and RadioGroup.Label
is used to specify labels for the radio group and individual radio buttons.
Any of these components can be styled using the className
prop.
import { RadioGroup } from "@headlessui/react";
export default function App() {
const [plan, setPlan] = useState("startup");
// tailwind css classes
const groupLabelClassNames = "text-gray-700 text-xl font-bold block mb-2";
const optionLabelClassNames =
"px-4 border-2 border-solid border-blue-500 rounded-full mx-1";
const ringClassNames =
" ring-2 ring-white ring-opacity-60 ring-offset-2 ring-offset-sky-300";
return (
<form aria-label="Radio Form">
<RadioGroup
value={plan}
onChange={setPlan}
className="block p-5"
name="plan"
>
<RadioGroup.Label className={groupLabelClassNames}>
Plan
</RadioGroup.Label>
{plans.map((plan) => {
return (
<RadioGroup.Option
key={plan.name}
value={plan.name}
className="inline-block focus:outline-none"
>
<RadioGroup.Label
className={optionLabelClassNames}
>
<span aria-hidden="true">⚬ </span>
{plan.label}
</RadioGroup.Label>
</RadioGroup.Option>
);
})}
</RadioGroup>
</form>
);
}
Managing State
There are three ways to manage the state. When I say manage state I mean knowing the status of checked, unchecked, active, etc states of the radio buttons.
Using Tailwind CSS plugin
The @headlessui/tailwindcss
plugin needs to be installed in package.json
and then add this plugin in the tailwind.config.js
file.
// tailwind.config.js
module.exports = {
// ...
plugins: [
require('@headlessui/tailwindcss')
],
}
The state can now be referenced directly in the Tailwind CSS class names. For example, we can use the code below to update the UI when a radio button is checked using the ui-checked:
selector.
const optionLabelClassNames =
"text-blue-500 ui-checked:bg-blue-500 ui-checked:text-white";
Using Data Attributes
Headless UI spits out HTML with the data-headlessui-state
attribute after rendering. This attribute can be used to style using plain CSS.
// index.css
[data-headlessui-state*="checked"] {
background: blue;
color: white;
}
Using Render Props
Headless UI provides the use of render props for accessing the UI state inside the JSX. Below is our updated code that uses render props.
{plans.map((plan) => {
return (
<RadioGroup.Option
key={plan.name}
value={plan.name}
className="inline-block focus:outline-none"
>
{({ active, checked }) => (
<RadioGroup.Label
className={`${optionLabelClassNames} ${
checked ? " bg-blue-500 text-white" : " text-blue-500"
} ${active ? ringClassNames : ""}`}
>
{checked && <span aria-hidden="true">⚬ </span>}
{plan.label}
</RadioGroup.Label>
)}
</RadioGroup.Option>
);
})}
Accessibility
Headless UI takes care of the best practices for accessibility for non-screen users.
The HTML after rendering contains the respective roles, labels, and descriptions.
For example, the div
rendered for the RadioGroup
will have the role
of radiogroup
and the aria-labelledby
attribute.
The RadioGroup.Option
will have the role
of radio
and aria-labelledby
and aria-describedby
if provided. This also has the aria-checked
attribute for the currently checked radio button.
Headless UI also takes care of the keyboard users and provides standard radio button navigation using arrow keys and tabs.
Conclusion
Headless UI is a great and easy way to build custom input components with an amazing plugin for Tailwind CSS. The only thing you need to worry about is the CSS if you use Headless UI.
That’s all folks, thanks for reading!