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

10/8/2023
by Merlin
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 callingObject.assign(Header, …)
and then exportHeader
,Header
won’t haveH1
,H2
,H3
, andP
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;