I shipped a themeable component. It ignored every theme.

I shipped a themeable component. It ignored every theme.

# css# webdev# javascript# frontend
I shipped a themeable component. It ignored every theme.Juan David García Rincón

I spent a while building a credit card component with what I thought was a clean theming story: a...

I spent a while building a credit card component with what I thought was a clean theming story: a handful of CSS custom properties, documented, override whatever you like.

.crd {
  --crd-width: 340px;
  --crd-bg: linear-gradient(135deg, #111, #333);
}
Enter fullscreen mode Exit fullscreen mode

Then I actually tested it from the outside, the way a consumer would. Three separate bugs, none of which produced an error, all of which ended the same way: the library quietly won and the user's value did nothing.

Every one comes from a cascade rule that's easy to forget. If you publish components, you probably have at least one of these.

1. A declared value always beats an inherited one

The intent was that you could set a knob anywhere above the card and it would inherit down:

.checkout { --crd-bg: rebeccapurple; }
Enter fullscreen mode Exit fullscreen mode

Nothing happened.

Custom properties do inherit — that part is true. But inheritance only fills in where the element declares nothing itself. My stylesheet declared --crd-bg right on .crd, so the card's own value shadowed anything coming from above. The ancestor was setting a variable that the card immediately overwrote with its default.

I confirmed it by reading the computed value on both elements:

Where --crd-bg: red was set What the card resolved to
on an ancestor the library's gradient ❌
on .crd itself red ✅

The docs said "on .crd or any ancestor". Half of that sentence was fiction, and it had been fiction since the first release.

2. Unlayered CSS beats @layer, whatever the specificity

The same design was supposed to make Tailwind work for free. Every knob is a custom property, so an arbitrary-property utility should just set it:

<Card className="[--crd-radius:1.25rem] [--crd-bg:var(--color-indigo-600)]" />
Enter fullscreen mode Exit fullscreen mode

It didn't. Not "sometimes" — never.

Tailwind v4 emits its utilities inside @layer utilities. My stylesheet was unlayered. And in the cascade, layer order is compared before specificity, with unlayered normal declarations treated as the highest-priority layer. So a plain .crd { … } outranks every utility Tailwind can generate, no matter how specific the utility looks.

Tested against real Tailwind (v4.3.3), not from memory:

Utility on the card Result
[--crd-bg:…] library gradient wins ❌
[--crd-radius:1.25rem] stays 14px ❌

This one is nastier than the first, because !important and higher specificity don't help. Layers sit above both.

3. Your className may not be reaching the element you think

Then a third one, and this is the one I'd bet is most common.

The React wrapper rendered a container div and mounted the card inside it:

return <div ref={containerRef} className={className} />;
Enter fullscreen mode Exit fullscreen mode

Reasonable-looking. But the card is the child of that div, so className landed on the wrapper — and thanks to bug #1, a custom property on the wrapper never reached the card anyway. Two bugs stacking into one silent failure.

Measured on a real rendered component:

Where the class went Card's --crd-radius
the mount container (what className did) 14px ❌
the card element itself 99px ✅

Worth checking in your own library: className landing on a wrapper instead of the component root is invisible until someone tries to theme through it. Every mainstream library — Mantine, HeroUI, Radix — puts it on the root, and users assume that.

The fix: never declare, always fall back

The repair for the first two is one idea. Don't declare your knobs anywhere. Read them, and put the default in the var() fallback at the point of use:

/* before — the default is a declaration, so it shadows everything */
.crd { --crd-bg: linear-gradient(…); }
.crd__front { background: var(--crd-bg); }

/* after — the default is a fallback, so any value the user sets wins */
.crd__front { background: var(--crd-bg, linear-gradient(…)); }
Enter fullscreen mode Exit fullscreen mode

Now a value set on an ancestor, by a utility class, or inline all reach the card, because the card no longer declares anything to shadow them.

There's a wrinkle if you ship themes of your own. My per-brand styles also set --crd-bg, which would recreate the same shadowing. So the themes feed a private slot instead, and the public knob outranks it:

.crd--brand-visa { --_crd-bg-theme: linear-gradient(…); }
.crd__front      { background: var(--crd-bg, var(--_crd-bg-theme, <default>)); }
Enter fullscreen mode Exit fullscreen mode

Read outward-in: the user's value, else the brand theme, else the base default.

For the third bug the fix is just as small — merge className into the classes applied to the component's root element rather than putting it on the mount container.

The result is that Tailwind now works with no plugin, no config, and no cascade layer:

<Card className="[--crd-radius:1.25rem] [--crd-bg:var(--color-indigo-600)]" />
Enter fullscreen mode Exit fullscreen mode

The one case that still needs a layer

Being precise, because this took me a while to separate: the fallback trick fixes custom properties. It does nothing for regular ones.

If a user writes text-2xl on a slot and your stylesheet sets font-size on that slot, you're back to unlayered-beats-layered and the library still wins. The only real fix there is to ship your CSS wrapped in a cascade layer the consumer can order:

@layer crd-ui, theme, base, components, utilities;
Enter fullscreen mode Exit fullscreen mode

Mantine does this (styles.layer.css), PrimeReact has a cssLayer option. I copied the pattern — a second stylesheet entry point, wrapped, so the consumer opts in.

What I was building

All of this came out of crd-ui — a credit and debit card component for payment forms and saved-card views. Live brand detection, formatting, a flip when the CVC is focused, and a display layout for cards a user already owns. Zero runtime dependencies, MIT, and the same component ships for React, Vue, Svelte and vanilla JS from one package.

It's display-only on purpose: it never handles real card data, so it composes with providers that keep the number inside their own iframes.

If you're on react-credit-cards — unmaintained since 2020 — the prop names line up closely enough that migrating is mostly the import and the stylesheet. There's a prop-by-prop guide, including the two props that genuinely don't map.

Mostly, though: go check whether your own component actually honours the theme someone sets on it. Mine didn't, for three releases, and nothing ever threw an error.