Classed components

if you use Tailwind CSS, this is gonna change your life

10/8/2023

by Merlin

By Bogdan C. Rogulin on Unsplash

Shortly after starting to use Tailwind CSS, I had an idea:

Why don’t we have styled-components for Tailwind?
Something like classed-components.

Turns out, there’s an excellent npm package with that exact name.
There also is tw-classed which has a VERY similar approach to this article.

Here’s what it looks like:

const Button = classed.button(
	"font-semibold bg-white hover:bg-pink-300 rounded-full px-4 py-2"
);

Now you can use this button in all function components in your file.

The real draw of this is that you can still give your button a class, and it’ll get merged:

<Button class="ml-auto mt-4" />

// becomes

<button class="font-semibold bg-white ... ml-auto mt-4" />

Nested components

If you’re working larger components (with many elements inside them), but want control over each element, you can create nested components:

const Header = classed.header("flex flex-col text-center");

const H1 = classed.h1("text-3xl font-bold");
const H2 = classed.h2("text-2xl font-semibold");
const H3 = classed.h3("text-lg font-semibold");

const P = classed.p("text-gray-400");

export default Object.assign(Header, { H1, H2, H3 });
import Header from "./header"; // <- just one import!

<Header>
	<Header.H1>A 6 B 9 F</Header.H1>
	<Header.H2>C F 8 X J B L S 7</Header.H2>
	<Header.H3>Q W O M T 4 2 6 Z 5 T 2 8 G</Header.H3>
	<Header.P>this is a snellen chart</Header.P>
</Header>;

A 6 B 9 F

C F 8 X J B L S 7

Q W O M T 4 2 6 Z 5 T 2 8 G

this is a snellen chart

Note: We’re exporting whatever Object.assign() returns, because that way, TypeScript infers the type correctly.

If you’re simply calling Object.assign(Header, …) and then export Header, Header won’t have H1, H2, H3, and P as properties.

Dynamic styles

You might have heard of clsx or classNames before. They’re a nicer .join() for arrays containing CSS classes.

We can use them to add some color to our button:

const base = "font-medium rounded-full px-4 py-2 transition-colors";

const colors = {
	blue: "bg-blue-500 hover:bg-blue-600",
	indigo: "bg-indigo-500 hover:bg-indigo-600",
	purple: "bg-purple-500 hover:bg-purple-600",
	fuchsia: "bg-fuchsia-500 hover:bg-fuchsia-600",
};

type Props = {
	color?: keyof typeof colors;
};

export const Button = classed.button<Props>(props => clsx(base, colors[props.color]));
import { Button } from "./button"

<Button color="blue">Blue</Button>
<Button color="indigo">Indigo</Button>
<Button color="purple">Purple</Button>
<Button color="fuchsia">Fuchsia</Button>

cva

cva is a small package that lets you mash different variants (like the color prop above) together.

Here’s what it looks like with our classed function:

const Button = classed.button(
	cva({
		base: "font-semibold rounded-full px-4 py-2 transition-colors",
		variants: {
			color: {
				blue: "bg-blue-500 hover:bg-blue-600",
				white: "text-gray-900 bg-white hover:bg-gray-300",
			},
			size: {
				small: "px-2 py-1 text-sm",
				base: "px-4 py-2 text-base",
				large: "px-6 py-3 text-lg",
			},
			loading: {
				true: "animate-pulse pointer-events-none",
			},
		},
		defaultVariants: {
			color: "white",
			size: "base",
		},
		compoundVariants: [
			{
				// If a button is both size="small"
				// and color="blue", make it glow
				color: "blue",
				size: "small",
				class: "shadow-xl shadow-blue-500/75",
			},
		],
	})
);
import { Button } from "./button"

<Button>Default</Button>

<Button loading>Loading</Button>

<Button color="blue" size="large">Blue large</Button>
<Button color="blue">Blue</Button>
<Button color="blue" size="small">Blue small + glow</Button>

Wrapping other components

If you want to use classed with other components, like a Next.js <Link>, you can wrap them:

import NextLink from "next/link";

const Link = classed(NextLink)("text-blue-500 underline");

<Link href="/about">About us</Link>;

 

You could also create a base component and derive from it:

const BaseButton = classed.button("font-semibold rounded-full p-2");

const BigButton = classed(BaseButton)("text-lg");

The Full Thing™

This implementation is for SolidJS.

If you’re looking for a React implementation, please check out tw-classed. (Also has another SolidJS implementation)

import { cx, type cva, type VariantProps } from "cva";
import type { ComponentProps, JSX, ValidComponent } from "solid-js";
import { Dynamic } from "solid-js/web";

type ClassValue = ClassArray | ClassDictionary | string | number | null | boolean | undefined;
type ClassDictionary = Record<string, any>;
type ClassArray = ClassValue[];
type ClassProps = { class?: ClassValue; className?: never } | { class?: never; className?: ClassValue };

type GetClassName = ((props: ClassProps) => string) | string;

function _classed<T extends ValidComponent = ValidComponent, C extends GetClassName = GetClassName>(
	component: T,
	getClassName: C
) {
	const Dyn = Dynamic as (props: ComponentProps<T>) => JSX.Element;

	return function Comp<P = {}>(
		props: (C extends ReturnType<typeof cva> ? VariantProps<C> : unknown) & ComponentProps<T> & P
	) {
		return (
			<Dyn
				component={component}
				{...props}
				class={typeof getClassName === "string" ? cx(getClassName, props.class) : getClassName(props)}
			/>
		);
	};
}

// Create a proxy that will convert classed.button to _classed("button", ...)
// This way, web components should work as well (not tested)
const classed = new Proxy(_classed, {
	apply(target, _thisArg, [component, getClassName]: [string, GetClassName]) {
		return target(component, getClassName);
	},
	get(target, prop, _receiver) {
		return (getClassName: GetClassName) => target(prop as string, getClassName);
	},
}) as typeof _classed & {
	[T in keyof JSX.HTMLElementTags]: <P = {}, C extends GetClassName = GetClassName>(
		getClassName: C
	) => (props: (C extends ReturnType<typeof cva> ? VariantProps<C> : unknown) & ComponentProps<T> & P) => JSX.Element;
};

export default classed;