DEV Community

Cover image for 🩺Deep dive into ShadCN UI
Chan
Chan

Posted on • Updated on

🩺Deep dive into ShadCN UI

Introduction

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.

Troubleshooting: Style <label/> element based on radio input state

Requirements

Design Requirement

  • 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.

First Approach: Use TailwindCSS pseudoclass

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>
Enter fullscreen mode Exit fullscreen mode

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

Rendered HTML Elements on 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

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 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

Applied text color on Chrome Devtools

  • Overrided text color on Chrome Devtools

Overrided text color on Chrome Devtools

  • The order of tailwind styling

The content of global.css file

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>
Enter fullscreen mode Exit fullscreen mode

Troubleshooting: <RadioGroupItem/> should be hidden from the screen but still be accessible

It was necessary to hide radio buttons and display only labeled texts.

Using display: none attribute

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"}
/>
Enter fullscreen mode Exit fullscreen mode

Accessibility Tree on Chrome Devtools when display: none is used

Using visibility: hidden attribute

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"}
/>
Enter fullscreen mode Exit fullscreen mode

Accessibility Tree on Chrome Devtools when visibility: hidden is used

Using height: 0 attribute

It also removes the element from the tree.

Using sr-only className in tailwindCSS

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;
Enter fullscreen mode Exit fullscreen mode

It hides the element from the screen completely. Plus it's still accessibl
UI when sr-only is used

Accessibility Tree on Chrome Devtools when sr-only is used

Form Component

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

zod

You define schema using this library. Schema is a set of more strict rules for each data type than static typescript.

react-hook-form

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 like register, handleSumbit and formState.
export default function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<Inputs>()
  const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data)
Enter fullscreen mode Exit fullscreen mode
  • <FormProvider/>: This react-hook-form component allows your components to subscribe to the useForm() props and methods.

  • <FormField/>:This component provides name context to <Controller /> component. <Controller /> component is a react-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>
  )
}
Enter fullscreen mode Exit fullscreen mode
  • useFormField(): it extracts all the values given by FormFieldContext, FormItemContext, and useFormContext(). useFormContext() allows you to access form state(getFieldState and formState props). It returns fieldState, 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,
  }
}
Enter fullscreen mode Exit fullscreen mode

<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>
  )
})
Enter fullscreen mode Exit fullscreen mode
  • <FormLabel/>: This component gets error, formItemId from useFormField() hook. error is used to style the label text, and formItemId is used to refer to the targe form item using htmlFor 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}
    />
  )
})
Enter fullscreen mode Exit fullscreen mode
  • <FormControl/>: This component gets error, formItemId, formDescriptionId, formMessageId from useFormField() 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}
    />
  )
})
Enter fullscreen mode Exit fullscreen mode
  • <FormDescription/>: This component gets formDescriptionId from useFormField() hook. formDescriptionId is used to target the element and refer to it using aria-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}
    />
  )
})
Enter fullscreen mode Exit fullscreen mode
  • <FormMessage/>: This component gets formMessageId, error from useFormField() hook. formMessageId is used to target the element with the id and refer to it using aria-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>
  )
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

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.

References

Styling based on sibling state - Official Docs
Dynamic class names - Official Docs
Specificity - MDN
zod
FormProvider
useForm
useFormContext
getFieldState
Controller
Render Props Pattern

Top comments (0)