simeonGriggs.dev
Twitter
GitHub

Conditional class names using DOM attributes as state

Style elements based on the state of their DOM attributes—instead of template logic—for more easily debuggable styles.

View in Sanity Studio

2024-09-30

Adam Wathan recently tweeted this tip, and after using the approach in a project, I'm sold. While the tweet alone is explanatory enough, I figured it could be useful to write a complete guide.

This guide uses code examples to explain conditional class names when using JSX templates, you may adapt the approach for other languages.

Let's start with this basic list of "products," rendered in a flex column. Ideally you'd want to style them differently based on the value of the status key.

const PRODUCTS = [
{ name: "Cap", status: "available" },
{ name: "Shirt", status: "sold-out" },
{ name: "Socks", status: "coming-soon" },
];
export default function Products() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
{PRODUCTS.map((product) => (
<div key={product.name} className="bg-gray-200 p-4">
{product.name}
</div>
))}
</div>
);
}

Traditional approaches to conditional class names

The simplest way to conditionally style these is with a template literal. Instead of supplying a static string to classNames, you write logic inside template literals to compute a string value.

This usually looks okay for boolean values where you can use a ternary:

product.isAvailable ? `bg-green-200` : `bg-red-200`

But our data has three possibilities, so the complete template code looks like this:

const PRODUCTS = [
{ name: "Cap", status: "available" },
{ name: "Shirt", status: "sold-out" },
{ name: "Socks", status: "coming-soon" },
];
export default function Products() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
{PRODUCTS.map((product) => (
<div
key={product.name}
className={`p-4 ${product.status === "available" && `bg-green-200`} ${product.status === "sold-out" && `bg-red-200`} ${product.status === "coming-soon" && `bg-gray-200`}`}
>
{product.name}
</div>
))}
</div>
);
}

It works, and it looks awful.

Enter clsx

clsx, and others like them, are basic utilities that make the dynamic creation of a list of class names more readable in your templates. It concatenates all of the arguments you pass to the function.

And it does a good job.

import clsx from "clsx";
const PRODUCTS = [
{ name: "Cap", status: "available" },
{ name: "Shirt", status: "sold-out" },
{ name: "Socks", status: "coming-soon" },
];
export default function Products() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
{PRODUCTS.map((product) => (
<div
key={product.name}
className={clsx(
`p-4`,
product.status === "available" && `bg-green-200`,
product.status === "coming-soon" && `bg-gray-200`,
product.status === "sold-out" && `bg-red-200`,
)}
>
{product.name}
</div>
))}
</div>
);
}

The final list of class names is much simpler to read in this template. And this overly simplified example looks fine. However more complex projects could benefit from another method.

The resulting output in the DOM isn't very easily debuggable. The rendered elements all have unique lists of class names. Testing how the components behave as they change states requires changing the data in the app in development. Something you cannot do with the production front-end.

Using data attributes

The solution is to store the component's "state" in data attributes and conditionally apply styles based on the element—not the data in the template.

const PRODUCTS = [
{ name: "Cap", status: "available" },
{ name: "Shirt", status: "sold-out" },
{ name: "Socks", status: "coming-soon" },
];
export default function Products() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
{PRODUCTS.map((product) => (
<div
key={product.name}
data-status={product.status}
className="p-4 data-[status='available']:bg-green-200 data-[status='coming-soon']:bg-gray-200 data-[status='sold-out']:bg-red-200"
>
{product.name}
</div>
))}
</div>
);
}

Now, I will acknowledge that the data-[status=... class names look a bit much, and it might be nice for either some short hand to exist – or perhaps for the Tailwind Prettier plugin to break them onto new lines – but I believe the benefits of having DOM-stateful styling for elements is worth it.

It means you can debug components – even in production – by modifying the DOM in dev tools.

Using group attributes

Another useful tip shared in the replies to the original tweet was to style children based on the data attributes of a parent. In the contrived example below, the name of the product has a line through it when sold out, thanks to the group class name on the parent.

const PRODUCTS = [
{ name: "Cap", status: "available" },
{ name: "Shirt", status: "sold-out" },
{ name: "Socks", status: "coming-soon" },
];
export default function Products() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
{PRODUCTS.map((product) => (
<div
key={product.name}
data-status={product.status}
className="group p-4 data-[status='available']:bg-green-200 data-[status='coming-soon']:bg-gray-200 data-[status='sold-out']:bg-red-200"
>
<span className="group-data-[status='sold-out']:line-through">
{product.name}
</span>
</div>
))}
</div>
);
}

More DOM-as-state patterns

While the data attribute on elements is typically used as a catch-all for any extra information that needs to be stored on it, any element may also have attributes which have semantic meaning. These can also be used for conditional styling.

Styling based on ARIA attributes

In this contrived example, consider a badge which needs to render a name, and if the item is locked, also the word "Locked." You could conditionally render the latter part using your template logic.

export function ProductBadge({ name, locked }) {
return (
<div>
<span>{name}</span>
{locked ? <span>Locked</span> : null}
</div>
);
}

This works, but it introduces layout shift (a new element renders suddenly, changing the dimensions of its container) and makes it hard to animate in or out.

It is better to always render the element, but also with the aria-hidden attribute so that the text should be ignored by assistive technologies when the component is hidden.

export function ProductBadge({ name, locked }) {
return (
<div>
<span>{name}</span>
<span
aria-hidden={locked}
className={clsx(
`transition-opacity duration-300`,
locked ? `opacity-100` : `opacity-0`,
)}
>
Locked
</span>
</div>
);
}

This is better, there's now no layout shift and the "locked" element fades in-and-out.

However, the locked state is used twice, and to keep the template "clean" we've introduced clsx again. We can reduce all this logic to a single use of the locked state and a single className string.

export function ProductBadge({ name, locked }) {
return (
<div>
<span>{name}</span>
<span
aria-hidden={locked}
className="hidden opacity-0 transition-opacity duration-300 aria-[hidden='true']:opacity-100"
>
Locked
</span>
</div>
);
}

Perfect. No layout shift, animated transition and no additional library. With aria-hidden as the source of truth for state, you can debug these styles in your browser's dev tools.

Styling form elements based on state

Another common need for conditional class names is the state of form elements, like buttons. There are built-in pseudo selectors for disabled, invalid and required — depending on the form element. These are explained in detail in the docs.