Jan. 30, 2024, 10 p.m.

My first impressions of Panda CSS

Tailwind CSS vs. Panda CSS: A comparison of styling solutions for the web, covering type safety, merging styles, and component variants.

Baptiste's Web Dev Journey

Update 03/19/2024: Theo - t3.gg reviewed my article and made a video about it. In turn, I replied to his review in a video and clarified a few points from the article.

Hey there!

It's the first issue of my newsletter for 2024. I wish you a Happy New Year with overcoming challenges, smashing goals, and loads of tech fun!

I've been using Panda CSS for a few weeks now to build the website and the demos of XState by Example, and I've got a lot of feelings I want to share with my fellow web developers.

Tailwind CSS brought a lot to the table

For a bit of history, Tailwind CSS was my go-to styling solution for nearly five years, and no styling solution has seduced me until recently. Tailwind CSS is excellent for a few reasons:

  • Styles are edited within the markup instead of being separated.
  • Final styles are predictable and it's easy to prototype.
  • It comes with an excellent-quality default theme and lets developers focus on what matters instead of choosing between a font-size of 14px or 15px.
  • Maintaining a website built with Tailwind CSS is more manageable because many problems are discarded, such as finding a good name for the CSS classes or dealing with specificity and cascade problems.
  • Tailwind generates atomic classes, keeping the bundle size tiny. It generates the same class once, no matter how often it's used.

I bought Tailwind UI – the paid templates built by the Tailwind CSS team – and use it for my clients and my side projects. Without exaggerating, it's one of the best purchases I've made. The ROI is high for the price it costs.

I learned much about CSS and designing user interfaces thanks to Tailwind CSS. The reason is that the default preset gives excellent tools to non-designers people. The sole job of finding colors that look good together is difficult. The ease of prototyping and the fantastic documentation quality can explain that, too.

Type-safe CSS-in-JS with build-time generated styles

I started hearing about Panda CSS on Twitter less than a year ago. Alexandre Stahmer – one of the core contributors to Panda CSS – was advocating for it. I only dug deeper recently when I asked myself what I would use to style my new project, XState by Example.

Panda CSS is a CSS-in-JS solution that extracts the styles at build-time instead of runtime, resulting in better performance. It looks like this:

import { css } from "./styled-system/css";

function Example() {
  return (
    <div
      className={css({
        mt: "4",
        fontSize: "xl",
        fontWeight: "semibold",
        bg: { base: "gray.50", _hover: "gray.200" },
        lg: { fontSize: "2xl", },
      })}
    >
      Panda CSS is the best.
    </div>
  );
}

And will generate the following markup:

<div class="mt_4 fs_xl font_semibold bg_gray.50 hover:bg_gray.200 lg:fs_2xl">
  Panda CSS is the best.
</div>

You can group styles by conditions (lg for large breakpoint) or by property (bg and then base and _hover). Tailwind CSS does not support this yet, but it is a game changer for readability.

Panda CSS relies on codegen to generate only the styles used in the project. What I love about Panda is that styles are type-safe: you define styles by calling the css() function, whose types only accept the styles existing in your project. The codegen also generates the type definition of this function so that it reflects Panda's configuration in the project. The codegen generates its output in a styled-system directory at the project's root.

To me, type safety is Panda CSS's most significant advantage over Tailwind CSS. With Tailwind, developers must use a VS Code extension to check the validity of the classes they wrote in a string. Tailwind's DX (Developer Experience) is deteriorated because styles are written in a string without any native type-checking possibility.

Merging styles

One caveat with Tailwind CSS is merging styles. Say we have a reusable component <Button />, and we want to override its vertical padding once. I would do:

function Button({ classNames }) {
  return (
    <button
      classNames={"px-2 py-1 bg-gray-100" + " " + classNames}
    >
      { /** ... */ }
    </button>
  );
}

<Button classNames="py-4" />

// Final classNames: "px-2 py-1 bg-gray-100 py-4"

The problem is that it's hard to determine if the vertical padding will be overridden. Tailwind will both generate py-1 and py-4 classes and put them in a CSS file with the same specificity, which makes the class defined last take precedence over the other one. But which one is it? I can't tell you!

There are many solutions to circumvent this, like using the !important keyword on the overriding class or expecting the React component to receive a property verticalPadding and letting the component choose which class to apply.

The ultimate solution is to use tailwind-merge. It finds the classes defining the same property and keeps the last.

import { twMerge } from 'tailwind-merge';

twMerge("px-2 py-1 bg-gray-100", "py-4")

// Returns: "px-2 bg-gray-100 py-4"

Panda CSS is even simpler: it's supported out-of-the-box.

import { css } from "./styled-system/css";

css({ px: "2", py: "1", bg: "gray.100" }, { py: "4" })

// Returns: "px_2 py_4 bg_gray.100"

Component variants

I've often written code like that to create a badge component with variants:

type AppBadgeStatus = "pending" | "success" | "error";

interface AppBadgeProps {
  status: AppBadgeStatus;
  children: React.ReactNode;
}

function AppBadge({ status, children }: AppBadgeProps) {
  const classesForStatus: Record<AppBadgeStatus, string> = {
    success: "bg-green-100 text-green-800",
    pending: "bg-yellow-100 text-yellow-800",
    error: "bg-red-100 text-red-800",
  };

  return (
    <span
      className={
        `inline-flex items-center font-medium ${classesForStatus[status]}`
      }
    >
      {children}
    </span>
  );
}

The code becomes even more complex with more variants, like the badge size. The issue with merging styles is also relevant here.

Class Variance Authority is a more robust pattern for building component variants. A library specifically for Tailwind CSS is called Tailwind Variants. I've never used it, but I would probably if I had to create a library of components with Tailwind.

Panda CSS supports it out-of-the-box—once again:

import { styled } from "./styled-system/jsx";
import { cva, type RecipeVariantProps } from "./styled-system/css";

export const appBadge = cva({
  base: {
    display: "inline-flex",
    alignItems: "center",
    fontWeight: "medium",
  },
  variants: {
    status: {
      success: {
        color: "green.800",
        bg: "green.100",
      },
      pending: {
        color: "yellow.800",
        bg: "yellow.100",
      },
      error: {
        color: "red.800",
        bg: "red.100",
      },
    },
  },
});

// Define the component

// 1.

type AppBadgeVariants = RecipeVariantProps<typeof appBadge>;
// { status: "success" | "pending" | "error" }

type AppBadgeProps = AppBadgeVariants & { children: React.ReactNode };

export function AppBadge(props: AppBadgeProps) {
  return (
    <span className={appBadge(props)}>
      {props.children}
    </span>
  );
}

// 2.

export const AppBadge = styled("span", appBadge);

The variants are strongly typed automatically: you must pass the status variant when using the appBadge styles or the AppBadge component, and provide one of the defined options.

It's pretty good. I like how the library has been carefully thought out as a whole.

There is much more to discover

Panda CSS is a recent library with many exciting and well-made features. Amongst all its features, I especially like the following:

  • Slot Recipes. Some UI elements need to be separated into several parts. For a dialog component, there might be trigger, content, title, description and close trigger parts. You would want to define their styles as part of the same group of component variants. Slot Recipes offer this, and Park-UI uses them to define component styles.
  • Config Recipes. By default, Panda generates the styles for all the cva variants, even those never used at runtime. Using Config Recipes, Panda CSS can generate the CSS only for the actually used.
  • Patterns. Patterns are reusable groups of styles that can be combined with others. Panda comes with many of them by default, like vstack() or circle().
  • Semantic Tokens. Sometimes, giving meaning to a color might help: instead of using red.400 to style an error badge, using the error color would make sense. Semantic Tokens allow this.
  • Arbitrary Selectors. As with Tailwind, arbitrary selectors can be used to target any element.
  • JSX components with style props. This is the way to go for those who like defining styles as component props! Add padding by doing: <Box p="4" />.
  • Panda Studio. It's a website generated from the Panda's configuration of your project, making it possible to visualize it. Many tools have been built with the same idea, like the Viewer feature of the Nuxt Tailwind plugin.

If you want to know more about Panda CSS, Alexandre Stahmer wrote a thorough article explaining each of its features. Segun Adebayo, the creator of Chakra UI and Panda CSS, wrote about the roadmap to Panda CSS v1.

This is all for today. I hope you are intrigued by Panda CSS and will look at it!

Best,
Baptiste

You just read issue #2 of Baptiste's Web Dev Journey.

Share on Twitter Share on LinkedIn
Blog GitHub YouTube X
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.