The anatomy of shadcn/ui

If you were roaming around in the JavaScript ecosystem this year you might have come across this interesting UI library called shadcn/ui. Instead of being distributed as a npm package, the shadcn/ui components are delivered through a CLI that puts the source code of the components into your project itself. The creator mentions the reason for this decision in the official website for shadcn/ui,

Why copy/paste and not packaged as a dependency?

The idea behind this is to give you ownership and control over the code, allowing you to decide how the components are built and styled.

Start with some sensible defaults, then customize the components to your needs.

One of the drawbacks of packaging the components in an npm package is that the style is coupled with the implementation. The design of your components should be separate from their implementation.

In essence, shadcn/ui is not just another component library but a mechanism to declare a design system as code.

My intention with this article is to explore the architecture and implementation of shadcn/ui to review how it has been designed achieve the before mentioned goals.

If you haven't tried out shadcn/ui yet I would suggest visiting the shadcn/ui docs and play around a bit with it to get the most out of this article.

Prologue

Any user interface can be broken down into a set of primitive reusable components and their compositions. We can identify any given UI component to be constituent of its own behavior set and a visual presentation of a given style.

Behavior

Except from purely presentational UI components, UI components should be aware of user interactions that can be performed on them and should react accordingly. The foundations necessary for these behaviors are built-in to the native browser elements and available for us to utilize. But in modern user interfaces we need to present components that contains behaviors that cannot be satisfied by native browser elements only. (Tabs, Accordions, DatePickers etc.) This warrants the need to build custom components that looks and behave as we conceptualize.

Building custom components is usually not difficult to implement at the surface level using modern UI frameworks. But most of the time these implementations of custom components tend to overlook some very important aspects of the behavior of a UI component. This includes behaviors such as focus/blur state awareness, keyboard navigation and adhering to WAI-ARIA design principles. Even though behaviors are very important to enable accessibility in our user interfaces, getting these behaviors right according to W3C specifications is a really hard task and could significantly slow down product development.

Given the fast moving culture of modern software development, it is difficult to factor in accessibility guidelines into custom component development for front-end teams. One approach a company could follow to mitigate this would be to develop a set of unstyled base components that already implements these behaviors and use them in all projects. But each team should be able to extend and style these components effortlessly to fit the visual design of their project.

These reusable components that are unstyled but encapsulate their behavior set are known as Headless UI components. Often these can be designed to expose a API surface to read and control their internal state. This concept is one of the major architectural elements of shadcn/ui.

Style

The most tangible aspect of UI components are their visual presentation. All Components have a default style based on the overall visual theme of the project. The visual elements of a component is two-fold. First is the structural aspect of the component. Properties such as border radius, dimensions, spacing, font-sizes and font-weights contributes to this aspect. The other aspect is the visual style. Properties such as foreground and background colors, outline, border contributes to this aspect.

Based on user interactions and application state, a UI component can be in different states. The visual style of a component should reflect the current state of the component and it should provide feedback to the users when they interact with it. Therefore different variations of the same UI component should be created order to accomplish this. These variations, often known as variants are built by adjusting the structure and visual style of a component to communicate its state.

During the development lifecycle of a software application, a design team captures the visual theme, components and variants when developing high-fidelity mockups for the application. They would also document different intended behaviors of components as well. This type of a collective design documentation for a given software is usually known as the Design System.

Given a design system, the responsibility of a front-end team would be to express it in code. They should capture the global variables of the visual theme, reusable components and their variants. The main benefit of this approach is that any change done in the future to the design system can be efficiently reflected in code. This would unlock a friction-less workflow between the design and development teams.

Architecture Overview

As we have discussed before shadcn/ui is a mechanism by which design systems can be expressed in code. It enables a front-end team to take a design system and transfer it a format that can be utilized in the development process. I think that the architecture that enables this workflow is worthy of our review.

We are able to generalize the design of all shadcn/ui components to the following architecture.

shadcn/ui achitecture overview

shadcn/ui is built on the core principle that states The design of your components should be separate from their implementation. Therefore every component in shadcn/ui has a 2 layered architecture. Namely,

  1. Structure and behavior layer
  2. Style layer

Structure and behavior layer

In the structure and behavior layer, the components are implemented in their headless representations. As we discussed in the prologue, this means their structural composition and core behaviors are encapsulated within this representation. Even the difficult considerations such as keyboard navigation and WAI-ARIA adherence are also implemented by each component in this layer.

shadcn/ui has utilized some well established headless ui libraries for the components that cannot be implemented with only the native browser elements. Radix UI is one such key headless UI library that can be found in the shadcn/ui codebase. Several commonly used components like the Accordion, Popover, Tabs etc... are built on top of their Radix UI implementations.

shadcn/ui structre and behavior layer overview

Native browser elements and Radix UI components are enough to satisfy most of the component requirements. But there are situations that warrant the usage of specialized headless UI libraries.

One such situation is form handling. For this purpose, shadcn/ui provides a Form component that is built on top of React Hook Form headless form library which handles the form state management requirements. shadcn/ui takes the primitives given by React Hook Form and wraps over them in a composable manner.

To handle table views, shadcn/ui uses the headless table library Tanstack React Table. The shadcn/ui Table and DataTable components are built on top of this library. Tanstack React Table exposes a number of APIs to handle table view including filtering, sorting and virtualization.

Calender views, DateTime pickers, and DateRange pickers are among some notorious components that are hard to get right. But shadcn/ui uses the package React Day Picker as the base component to implement the headless layer of aforementioned components.

Style layer

TailwindCSS lies at the core of the shadcn/ui style layer. However values for attributes such as color, border radius are exposed to the Tailwind configuration are placed in a global.css file as CSS variables. This can be used to manage variable values shared across the design system. If we are using Figma as the design tool this approach can be used to track the Figma variables we would have in a design system.

In order to manage the differentiated styling for component variants, Class Variance Authority(CVA) is used by shadcn/ui. It provides a very expressive API surface to configure variant styling for each component.

As we have discussed the high level architecture of shadcn/ui we can now dive deep into the implementation details of several components. We can start this discussion from one of the simplest components in shadcn/ui.

shadcn/ui Badge

Badge

The implementation of the <Badge /> component is relatively simple. Therefore it is a good starting point to see how the concepts we have discussed so far can be used to build reusable components.

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

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

const badgeVariants = cva(
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
        secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

export { Badge, badgeVariants };

The implementation of the component starts with a call to the cva function from the library class-variance-authority . It is used to declare the variants of the component.

const badgeVariants = cva(
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
        secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

The first argument to the cva function defines the base styles that is applied to all variants of the <Badge/> component. As the second argument, cva accepts a configuration object that defines the possible variants of the component and the default variant that should be used. We can also notice the usage of utility styles that consume the design system tokens that have been defined in the tailwind.config.js which opens up the possibility to easily update the look and feel by adjusting the CSS variables

Invocation of the cva function returns another function; which an be used to apply the styles respective to each variant conditionally. We have stored it in a variablebadgeVariants so that we can use it to apply the correct styles when the variant name is passed to the component as a prop.

Then we can find the BadgeProps interface that defines the types for the component.

export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}

The base element for the badge component is a HTML div. Therefore, this component should be exposed to component consumers as an extension of the div element. This is achieved by extending the React.HTMLAttributes<HTMLDivElement> type. Additionally we need to expose a variant prop from the <Badge/> component to let the consumers render the required variant of the component. The helper type VariantProps allows us to expose the available variants as an Enum on the variants prop .

function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

Finally we have the functional component that defines the Badge. Here we can notice that we are collating all props other than className and variant into a props object that we spread on the underlying div. This allows component consumers to interface with all the props available on a div element.

Notice how the style application is handled on the component. The value of the variant prop is passed into the badgeVariants function, which would return the class string that contains all the utility class names required to render the component variant. However we can notice that we are passing the return value of the aforementioned function and the values passed into the className prop through a function named cn before its evaluated into the className attribute of the div element.

This is actually a special utility function that's provided by shadcn/ui. It's role is to act as a utility class management solution. Let's take a look at the implementation of it.

import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

This utility function is a amalgamation of 2 libraries that helps manage utility classes. The first library is clsx. It provides the ability to conditionally apply styles via className concatenation to a component.

import React from "react";

const Link = ({ isActive, children }: { isActive: boolean, children: React.ReactNode }) => {
  return <a className={clsx("text-lg", { "text-blue-500": isActive })}>{children}</a>;
};

Here, we can see a situation where clsx has been used independently. By default the only the text-lg utility class will be applied to the Link component. But when we pass in the isActive prop to the component as a true value, text-blue-500 utility class will also be applied to the component.

But there are situations where clsx alone can't be used to achieve our goals.

import React from "react";
import clsx from "clsx";

const Link = ({ isActive, children }: { isActive: boolean, children: React.ReactNode }) => {
  return <a className={clsx("text-lg text-grey-800", { "text-blue-500": isActive })}> {children}</a>;
};

In this situation, the color utility text-grey-800 has been applied to the element by default. Our goal is to change the text color to blue-500 when the isActive prop becomes true. But due the nature how the CSS Cascade affects Tailwind, the color style applied by the text-grey-800 will not be modified.

This is where the library tailwind-merge comes in. If we modify the above code using tailwind-merge ,

import React from "react";
import { twMerge } from "tailwind-merge";
import clsx from "clsx";

const Link = ({ isActive, children }: { isActive: boolean, children: React.ReactNode }) => {
  return <a className={twMerge(clsx("text-lg text-grey-800", { "text-blue-500": isActive }))}>{children}</a>;
};

Now, the output of clsx will be passed through tailwind-merge . taiwlind-merge will parse the the class string and shallow merges the style definition. Which means the text-grey-800 is replaced by text-blue-500 so that the element reflects new styles that were applied conditionally.

This approach helps us make sure there won't be any style conflicts in our variant implementations. Since the className prop also passes through the cn util, it makes it really easy to override any styles if required. But this comes with a trade-off. Utilization of cn opens up the possibility for a component consumer to override the styles in an ad-hoc manner. Which would delegate some level of responsibility to he code review step to verify cn has not been abused. On the other hand, if you do not need to enable this behavior at all you can modify the component to use clsx only.

When we have analyze the implementation of the Badge component, we can notice some patterns, including some associated with SOLID:

  1. Single Responsibility Principle (SRP):

    • The Badge component appears to have a single responsibility, which is to render a badge with different styles based on the provided variant. It delegates the management of styles to the badgeVariants object.
  2. Open/Closed Principle (OCP):

    • The code seems to follow the open/closed principle by allowing for the addition of new variants without modifying the existing code. New variants can be easily added to the variants object in the badgeVariants definition.

    • But there's a caveat, due to how cn is utilized it is possible for a component consumer to pass new overriding styles using the className attribute. This could open the component for modification. Therefore, when you are building your own component library with shadcn/ui decide whether you should allow that behavior or not.

  3. Dependency Inversion Principle (DIP):

    • The Badge component and its styling are defined separately. The Badge component depends on the badgeVariants object for styling information. This separation allows for flexibility and easier maintenance, adhering to the Dependency Inversion Principle.
  4. Consistency and Reusability:

    • The code promotes consistency by using the utility function cva to manage and apply styles based on variants. This consistency can make it easier for developers to understand and use the component. Additionally, the Badge component is reusable and can be easily integrated into different parts of an application.
  5. Separation of Concerns:

    • The concerns of styling and rendering are separated. The badgeVariants object handles the styling logic, while the Badge component is responsible for rendering and applying the styles.

After analyzing the implementation of the Badge component, we now have a detailed understanding of the general architecture of shadcn/ui. But this was purely a display level component. So let's take some time to look at a few more interactive components.

shadcn/ui Switch

import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"

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

const Switch = React.forwardRef<
  React.ElementRef<typeof SwitchPrimitives.Root>,
  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
  <SwitchPrimitives.Root
    className={cn(
      "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
      className
    )}
    {...props}
    ref={ref}
  >
    <SwitchPrimitives.Thumb
      className={cn(
        "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
      )}
    />
  </SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName

export { Switch }

Here we have the Switch component which is commonly found in modern user interfaces to toggle a certain field between 2 values. Unlike the Badge component which was purely presentational, Switch is an interactive component that responds to user input and toggles its state. It also communicates its current state to the user via its visual style.

The primary method that a user could interact with a switch component by clicking/tapping the switch with a pointing device. Even though building a switch component that responds to pointer events is pretty straightforward, the implementation significantly increases in complexity when we need the switch to respond to keyboard interactions and screen readers as well. Some expected behaviors for the switch component can be identified as follows, 1. Responds to Tab key presses by focusing on the switch. 2. Once focused, pressing enter would toggle the state of the switch. 3. In the presence of a screen reader it should announce its current state to the user.

If we analyze the code carefully, we can notice that the actual structure of switch is built-up via the usage of the <SwitchPrimitives.Root/> and <SwitchPrimitives.Thumb/> compound components. These components are sourced from the RadixUI headless library and contains all the implementations for the expected behavior for a switch. We can also notice the utilization of the React.forwardRef to build this component. This makes it possible for this component to be bound to incoming ref s. Which is a useful feature when there's is a need to track the focus states and integration with certain external libraries. (Ex: In order to use the component as an input component with the React Hook Form library it should be focusable via a ref).

As we discussed before, RadixUI components doesn't provide any styling. Therefore the styles have been applied to this component via the className prop directly after passing through the cn utility function. We can also create variants for the component if required by using cva.

Conclusion

The architecture and anatomy of shadcn/ui that we have discussed so far is implemented in the same manner in rest of the shadcn/ui components as well. However, the behaviors and implementations of certain components are slightly more complex. Discussion of the architecture of these components deserves their own articles. Therefore I would not go into lengths but provide an overview of their structure.

  1. Calendar

    • Uses react-day-picker as the headless component.
    • Uses date-fns as the DateTime formatting library.
  2. Table and DataTable

    • Uses @tanstack/react-table as the headless table library.
  3. Form

    • Uses react-hook-form as the form and form state management library as the headless component.
    • Utility components that encapsulates form logic are exposed by shadcn/ui. These can be used to assemble parts of the form including inputs and error messages.
    • zod is used as the schema validation library for the form. Validation errors returned by zod are passed into <FormMessage/> components that display the errors beside form inputs.

shadcn/ui introduced a new paradigm in thinking about front-end development. Instead of relying on third party packages that that abstracts the whole component, we could own the implementation of the components and only expose the required elements. Rather than being limited to the opinionated API surface of a pre-built component library when applying your design system, build your own design system with good enough defaults that you can customize later.

A Profile Headshot Of Manupa Samarawickrama

Manupa Samarawickrama

HomeAboutBlogLet's Talk