Notes

Sergei Droganov

Pure CSS Tailwind Plugins

Problem

Recently, I began working on a product that required creating a handful of components. I wanted my UI library to have zero runtime and to be compatible with any front-end stack. I came up with the idea of incorporating a base (unstyled) version of Daisy UI and styling it to my own preferences.

However, the challenge was that Tailwind expects me to style my components using CSS-in-JS, whereas I strongly prefer writing plain CSS.

So, here is how I solved it.

Solution

Let's take one of the components and see how I created it. Here is the source of the Card component.

.card-hero {
  @apply bg-paper rounded-[2.5rem] sm:rounded-[4rem] p-[2.5rem] sm:p-[4rem]
    max-w-md mx-auto grid gap-4;

  figure {
    @apply mt-[-4rem] sm:mt-[-5.75rem];

    img {
      @apply rounded-xl sm:rounded-3xl w-full flex items-center justify-center
        text-xs text-opacity-40 border border-paper border-opacity-20 bg-neutral
        dark:bg-opacity-80 dark:brightness-[.64];
    }
  }

  .card-body {
    @apply text-center;
  }

  .card-title {
    @apply text-2xl font-light;
  }

  .card-subtitle {
    @apply text-sm font-light;
  }
}

Now, I need to parse it as a JavaScript object and make Tailwind happy:

import plugin from "tailwindcss/plugin";
import { loadStyles } from "../_tw/loadStyles.ts";

export const cardPlugin = plugin(async ({ addComponents }) => {
  const styles = await loadStyles("/ui/Card/Card.components.css");

  addComponents(styles);
});

What’s the magic of loadStyles?

import { parse } from "postcss";
import { objectify } from "postcss-js";
import type { Root } from "postcss-js";
import { getAbsPath, readCss } from "./fs.ts";

/**
 * Represents a CSS-in-JS object where each selector maps to its CSS properties.
 */
type type CSSInJS = {
  [selector: string]: null | string | string[] | CSSInJS;
};

/**
 * Transforms a CSS file with @apply directives into a CSS-in-JS object.
 *
 * @param {string} cssFilePath - The pathname to the CSS file.
 * @returns {Promise<CSSInJS>} - The resulting CSS-in-JS object.
 */

export const loadStyles = async (
  cssFilePath: string,
): Promise<CSSInJS> => {
  const absPath = getAbsPath(cssFilePath);
  const cssContent = await readCss(absPath);
  const processed = parse(cssContent) as Root;
  const jssObject: CSSInJS = objectify(processed);

  return jssObject;
};