Native Tailwind CSS class merger for PhiaUi.
Provides cn/1 — the primary utility for composing and merging Tailwind
class strings. It is the Elixir equivalent of the JavaScript combination of
clsx (conditional class joining) and tailwind-merge (conflict resolution).
Designed to be imported in component modules:
import PhiaUi.ClassMerger, only: [cn: 1]Behaviour
- Accepts a list of class strings,
nil, orfalsevalues. - Falsy values (
nil,false) are silently discarded, making it safe to pass conditional expressions directly in the list. - Individual strings may contain multiple space-separated tokens.
- Exact duplicate tokens are deduplicated; the last occurrence wins, matching the standard CSS cascade convention.
- When two classes belong to the same Tailwind conflict group (i.e. they
both target the same CSS property — see
PhiaUi.ClassMerger.Groups), the last one wins and the earlier one is removed entirely. - Results are memoised in
PhiaUi.ClassMerger.Cache(an ETS table) for zero-cost repeated calls with the same inputs.
Conflict Resolution Examples
Padding axis conflict — px-2 and px-4 both set padding-inline;
the later px-4 wins:
iex> PhiaUi.ClassMerger.cn(["px-2 py-1", "px-4"])
"py-1 px-4"Background colour conflict — only one bg-* class is kept:
iex> PhiaUi.ClassMerger.cn(["bg-blue-500", "bg-red-600"])
"bg-red-600"Text size conflict — text-sm and text-lg both set font-size:
iex> PhiaUi.ClassMerger.cn(["text-sm font-bold", "text-lg"])
"font-bold text-lg"Text colour vs text size — these are different groups and are both kept:
iex> PhiaUi.ClassMerger.cn(["text-sm", "text-red-500"])
"text-sm text-red-500"Falsy values are silently ignored:
iex> PhiaUi.ClassMerger.cn(["px-4 py-2", nil, false, "font-semibold"])
"px-4 py-2 font-semibold"Exact duplicates are deduplicated; last occurrence wins:
iex> PhiaUi.ClassMerger.cn(["px-4 py-2", "font-semibold", nil, "px-4"])
"py-2 font-semibold px-4"Empty list returns an empty string:
iex> PhiaUi.ClassMerger.cn([])
""Usage in Components
Every PhiaUI component uses cn/1 to merge its base classes with the
caller-supplied class override attribute:
def button(assigns) do
~H"""
<button class={cn(["px-4 py-2 rounded", @variant_class, @class])}>
<%= render_slot(@inner_block) %>
</button>
"""
endThis lets callers override individual utilities without duplicating the component's full class list:
# Replaces px-4 with px-8; everything else is unchanged.
<.button class="px-8">Wide Button</.button>Performance
cn/1 is called on every component render. The first call for a given
list of inputs runs the full resolution pipeline (tokenise → dedup → join)
and writes the result to the ETS cache. Subsequent calls with identical
inputs return the cached string directly from ETS in O(1) time, bypassing
the pipeline entirely.
Because the ETS table is configured with read_concurrency: true, reads from
concurrent LiveView processes are lock-free and do not serialise through any
single GenServer process.
Summary
Functions
Merges a list of class values into a single, conflict-resolved class string.
Functions
Merges a list of class values into a single, conflict-resolved class string.
Accepts String.t(), nil, or false elements. Returns "" for an
empty or fully-falsy list. When multiple classes target the same Tailwind
utility group, the last one in the list wins and all earlier ones are
removed.
Results are memoised in ETS. The first call for a given input list runs the full resolution pipeline; subsequent identical calls are O(1) cache hits.
Examples
iex> PhiaUi.ClassMerger.cn(["px-4 py-2", "font-semibold", nil, "px-4"])
"py-2 font-semibold px-4"
iex> PhiaUi.ClassMerger.cn(["bg-primary", "bg-secondary"])
"bg-secondary"
iex> PhiaUi.ClassMerger.cn([nil, false, nil])
""