Universal tab component for PhoenixKit.
Supports two modes with identical visual appearance:
Navigation tabs — each tab carries a URL, renders as <.link>:
<.nav_tabs active_tab="general" tabs={[
%{id: "general", label: "General", icon: "hero-cog-6-tooth", navigate: Routes.path("/admin/settings")},
%{id: "advanced", label: "Advanced", navigate: Routes.path("/admin/settings/advanced")}
]} />Event tabs — no URL, uses on_change via phx-click:
<.nav_tabs active_tab={@active_tab} on_change="switch_tab" tabs={[
%{id: "oban", label: "Oban Jobs"},
%{id: "scheduler", label: "Scheduler"}
]} />With badges (works in both modes):
<.nav_tabs active_tab={@tab} tabs={[
%{id: "followers", label: "Followers", patch: Routes.path("/connections?tab=followers"), badge: @followers_count},
%{id: "following", label: "Following", patch: Routes.path("/connections?tab=following"), badge: @following_count}
]} />Tab map keys
Required: :id, :label
Optional: :icon (Heroicon name), :badge (count/text), :badge_class
(a daisyUI tone such as "badge-warning", which wins over the active-tab
default), and at most one link key — :navigate, :patch, or :path.
Every optional key treats nil as absent, so the common
badge: if(count > 0, do: count) renders no badge rather than an empty one.
The link keys mirror Phoenix.Component.link/1 rather than inventing a
parallel vocabulary: :navigate for a full LiveView navigation, :patch
to stay in the current LiveView (query-param tabs want this — a :navigate
there remounts and loses socket state). Both pass through VERBATIM, again
like link/1 — build them with your module's Paths helpers (or
Routes.path/1 yourself). :path is the legacy
spelling: the same link KIND as :navigate, but with different prefix
rules — it is the one key the component still runs through
Routes.path/1, because its callers predate the link keys and have
always passed unprefixed paths. The two are NOT interchangeable: swapping
path: for navigate: while keeping an unprefixed value under-prefixes,
and the reverse double-prefixes. Setting more than one link key raises; a key whose
value is nil counts as absent, so a conditional path is safe.
A tab with no link key renders as a button and needs on_change; without
it the tab is inert and the component logs a warning rather than raising —
a dead tab should not take a whole LiveView down. A strip may freely mix
link tabs and event tabs.
Event payload
Event tabs dispatch phx-value-tab, so handlers match on:
def handle_event("switch_tab", %{"tab" => id}, socket)The key is deliberately not configurable, and deliberately not value:
LiveView's extractMeta overwrites meta.value with the element's own
.value DOM property, so a <button> (whose .value is "") silently
delivers an empty string unless a native value= attribute is also set.
Standardising on tab removes the trap rather than making it selectable.
Variants
variant={:boxed} (default) is the filled strip used across admin pages.
variant={:plain} drops the frame for tabs that sit inside an
already-framed container — a filter row inside a picker, say.
variant={:border} is daisyUI's underline look, the convention on
show-page and settings tab strips.
It exists because class can only ADD to the container: with the frame
baked in, a caller had no way to take it off, and hand-rolling the markup
was the only escape. That is how the copies started.
Summary
Functions
The daisyUI class list for a single tab. See tablist_class/2.
The daisyUI class list for a tablist container.
Functions
The daisyUI class list for a single tab. See tablist_class/2.
The daisyUI class list for a tablist container.
Exposed so anything rendering tab-styled markup that is NOT a tab strip
(segmented form controls, for instance) can share one definition instead
of repeating the class string. A daisyUI rename should be a change here,
not a sweep across every call site — tabs-boxed became tabs-box in
daisyUI 5 and had to be fixed in 21 places across 8 repositories.