Conflict-aware Tailwind class merging.
Why this exists
The naive approach to letting callers customise a component is string concatenation:
class={"bg-indigo-600 px-4 " <> @class}This does not work. With Tailwind, the winner between two conflicting utilities is decided
by their order in the generated stylesheet, not by their order in the class attribute.
class="bg-indigo-600 bg-red-500" renders whichever of the two Tailwind happened to emit
later — which the caller cannot predict or control.
cx/1 fixes this by removing the earlier of any two classes that target the same CSS
property, so the last one genuinely wins:
iex> cx(["bg-indigo-600 px-4 py-2 rounded-md", "bg-red-500 px-6"])
"py-2 rounded-md bg-red-500 px-6"Variants are scoped
Classes only conflict when their variant prefixes match, so light and dark values coexist:
iex> cx(["bg-white dark:bg-slate-900", "dark:bg-black"])
"bg-white dark:bg-black"Variant order does not matter — hover:md:flex and md:hover:block conflict.
Escape hatches
This resolver covers the utility groups Pine UI actually emits. It is deliberately conservative: an unrecognised class is always kept, so a class this module has never heard of can never be silently dropped.
If you already run a full-featured merger, delegate to it:
config :pine_ui_phoenix, class_merge: {TwMerge, :merge, []}The configured function receives a single space-joined string and must return a string.
Tailwind's ! important modifier is also honoured — !bg-red-500 is tracked separately
from bg-red-500, and wins in the cascade regardless of order.
Summary
Types
Functions
Merges Tailwind class inputs, dropping earlier classes that conflict with later ones.
Accepts strings, nil, false, and arbitrarily nested lists of the same — matching what
HEEx already allows in a class={...} attribute. Falsy entries are discarded, which makes
conditional classes read naturally:
cx([base, @variant_classes, @disabled && "opacity-50 cursor-not-allowed", @class])
Merges two class inputs. See cx/1.
Merges three class inputs. See cx/1.