- Introduction
- Troubleshooting: Style
<label/>
element based on radio input state - Troubleshooting:
<RadioGroupItem/>
should be hidden from the screen but still be accessible - Using
height: 0
attribute - Using
sr-only
className in tailwindCSS - Form Component
- Conclusion
- References
Radix-UI is a headless UI library that guarantees accessibility and ShadCN UI are components built on top of it, styled with tailwindCSS. These libraries are leading the trend and I got curious about them, so I jumped into it and I'm using them in my project. So far, I'm satisfied with its approach, but there were some issues since I missed some details while they abstracted away the HTML. In this post, I will talk about the issues I had while styling and customizing ShadCN UI Components.

- It should satisfy the styling of the given design.
- Both buttons should work as radio buttons
- It should take accessibility into consideration
Since ShadCN UI already takes consider of accessibility and radio functionalities are supported, I decided to use RadioGroup
and RadioGroupItem
.
peer-{modifier}
lets style a specific element based on sibling state. You can the sibling element with peer
className and style the target element by using peer-{modifier}
like peer-checked/peername:bg-green
.
Here's the blueprint of the code.
<RadioGroup>
<RadioGroupItem className="peer/overview" value="overview" id="overview" />
<Label htmlFor="overview" className="peer-checked/overview:border-pink">
...
</Label>
</RadioGroup>
However, the <Label/>
element was not styled with the peer-checked/overview:border-pink
className. I wrapped my head around this issue for a few hours, closely looked at Chrome Devtools for a few hours, and found the reason why it doesn't work.
HTML Element on Chrome Devtools

The actual rendered result of <RadioGroupItem/>
component is <button aria-role="radio"/>
, instead of <input type="radio"/>
. checked
. CSS does not evaluate aria-role unless it's specified directly like this:
[role="checked"] {
background-color: white;
}
At this stage, I realized it's not feasible to style <label/>
based on <input/>
state here, so I moved forward with another approach: dynamic styling.
Since I saw that it's difficult to manage the input state(checked
) supported in native HTML in this case, I managed the state by using useState()
react hook. After that, I constructed a dynamic className computed with the state and injected it.
At first, I tried it like this.
- Note that
onValueChange()
is the event handler prop for<RadioGroup/>
Component. - Note that
checked
prop is used in<RadioGroupItem/>
Component. - The dynamic style
border-pink even:text-pink
of<Label/>
specifies the text color of its second child element.
However, this element had a limitation. Since the direct declaration(className='text-black'
) is applied by @utilities
and the pseudoclass(even:text-pink
) is applied by @base
in the global.css
file, className='text-black
takes precedence over the pseudoclass and the text color of the second child doesn't change even if the radio button is set to checked
. You can check the results below provided by Chrome Devtools.
- Applied text color on Chrome Devtools

- Overrided text color on Chrome Devtools

- The order of tailwind styling

Therefore, instead of staying the child element in the parent component, I was bound to pass the js expression to the child component.
Here's the refactored code.
<RadioGroup value={selectedOption} onValueChange={setSelectedOption}>
<RadioGroupItem
value="overview"
id="overview"
checked={selectedOption === "overview"}
/>
<Label
htmlFor="overview"
className={`${
selectedOption === "overview" ? "border-pink even:text-pink" : ""
}`}
>
<div>...</div>
<div className="text-black">
this should turn pink when its radio input is checked, but it's still
black.
</div>
</Label>
</RadioGroup>
It was necessary to hide radio buttons and display only labeled texts.
This attribute removes the element from the accessibility tree, causing the UI to be inaccessible to screen readers.
<RadioGroupItem
className="hidden"
value="overview"
id="overview"
checked={selectedOption === "overview"}
/>

This attribute also eliminates the element from the tree. Plus, the element still takes up the box of the area.
<RadioGroupItem
className="invisible"
value="overview"
id="overview"
checked={selectedOption === "overview"}
/>

It also removes the element from the tree.
sr-only
applies the following CSS attributes to the element.
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
It hides the element from the screen completely. Plus it's still accessible.


ShadCN UI introduces how to use its <Form />
component. However, it was my first time using react-hook-form
and runtime validation library zod
altogether, so I didn't have any clue how I should write down some code. I decided to break all the example into pieces and take a look at what's really going on.
Here's the example. It's quite overwhelming.
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
You define schema using this library. Schema is a set of more strict rules for each data type than static typescript.
react-hook-form
helps you build forms faster and more performant. Each child component wouldn't cause rerender of any other children components.
useForm()
useForm()
hook returns form state including props likeregister
,handleSubmit
andformState
.
export default function App() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<Inputs>()
const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data)
-
<FormProvider/>
: Thisreact-hook-form
component allows your components to subscribe to theuseForm()
props and methods. -
<FormField/>
:This component providesname
context to<Controller />
component.<Controller />
component is areact-hook-form
component that gets props such as 'name', 'control', 'render'. A blog post here explains render prop pattern in detail, so check it out if you're interested.
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
useFormField()
: it extracts all the values given byFormFieldContext
,FormItemContext
, anduseFormContext()
.useFormContext()
allows you to access form state(getFieldState
andformState
props). It returnsfieldState
,formItemId
for each item,formDescriptionId
,formMessageId
, etc.
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
<FormItem/>
: This component generates an unique accessibility id for each component and wraps its children with the id
Provider.
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
<FormLabel/>
: This component getserror
,formItemId
fromuseFormField()
hook.error
is used to style the label text, andformItemId
is used to refer to the target form item usinghtmlFor
attribute.
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-red-500 dark:text-red-900", className)}
htmlFor={formItemId}
{...props}
/>
);
});
<FormControl/>
: This component getserror
,formItemId
,formDescriptionId
,formMessageId
fromuseFormField()
hook.Slot
component merges props onto its immediate child. You can see the full code in its repo.
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
<FormDescription/>
: This component getsformDescriptionId
fromuseFormField()
hook.formDescriptionId
is used to target the element and refer to it usingaria-describedby
in the input element.
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn(
"text-[0.8rem] text-slate-500 dark:text-slate-400",
className,
)}
{...props}
/>
);
});
<FormMessage/>
: This component getsformMessageId
,error
fromuseFormField()
hook.formMessageId
is used to target the element with the id and refer to it usingaria-describedby
in the input element.error
is used to display the error message.
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn(
"text-[0.8rem] font-medium text-red-500 dark:text-red-900",
className,
)}
{...props}
>
{body}
</p>
);
});
ShadCN UI is a powerful tool since you don't need to write all the repeated HTML/CSS. Custom styling ShandCN UI components might be tricky since they abstract away everything, so it's necessary to stay up to date with Radix-UI docs.
Styling based on sibling state - Official Docs Dynamic class names - Official Docs Specificity - MDN zod FormProvider useForm useFormContext getFieldState Controller Render Props Pattern