Phoenix UI: Tailwind v4, daisyUI and core_components
Verified against Phoenix 1.8, LiveView 1.2, Tailwind 4.3, daisyUI 5.5 — October 2026
RULES — Follow these with no exceptions
- Wrap every page in
<Layouts.app flash={@flash}>, and passcurrent_scope={@current_scope}when the app has auth. In 1.8 the app layout is a function component that you call from the template; it is not applied automatically. It already renders the flash group, so don't render a second one. - Tailwind is configured in
assets/css/app.css. There is notailwind.config.js. Keep@import "tailwindcss" source(none), and add an@sourceline for every directory outsidelib/my_app_webthat contains class names. Tailwind scans only the listed paths. - Write every class name as a complete literal.
"text-#{color}-500"produces no CSS. Map values to full strings (%{error: "badge-error"}) or use function clauses. Safelist with@source inline("...")only when the values really come from data. - Use daisyUI's semantic colors rather than raw palette colors. Write
bg-base-100,text-base-content,btn-primary,text-errorand the*-contentpairs, notbg-white,text-gray-900ortext-red-600. The semantic colors switch with the light/dark theme; hard-coded colors don't. - Use
core_components.exfirst, and extend it rather than writing one-off markup. To get a new look, add a variant, an attr or a new component in that module, and keep itsattr/slotdeclarations, includingvalues:andrequired:. - Passing
classto<.button>or<.input>replaces the default classes; it doesn't add to them.<.button class="w-full">losesbtn. Pass the full class list (class="btn btn-primary w-full") or add a variant. - Icons are
<.icon name="hero-…">with a literal name. The suffixes are none (outline 24),-solid,-miniand-micro.icon/1accepts onlynameandclass; any other attribute is a compile warning and isn't rendered. <.button>declares onlyhref navigate patch method download name value disabled.type="button"(which you need for a non-submitting button inside a form) is an "undefined attribute" warning, and that warning failsmix precommit(compile --warnings-as-errors). Addtypeto the button'sinclude:list.- Accessibility you add yourself: a
labelon every<.input>(a placeholder is not a label), anaria-labelon every icon-only button,<button>or<.link>for anything clickable (neverphx-clickon adiv), and<.focus_wrap>around modal content.
Related skills: phoenix-liveview-essentials covers forms with to_form, streams, JS commands and colocated hooks. deployment-gotchas covers assets.deploy and the digest in releases.
What the generator sets up
| Piece | Where | What it is |
|---|---|---|
| Tailwind CLI | {:tailwind, "~> 0.5"} in mix.exs; config :tailwind, version: "4.3.3", my_app: [...] in config/config.exs | A standalone binary (no Node) that mix tailwind.install downloads to _build/tailwind-<target>-<version> |
| daisyUI | {:daisyui, github: "saadeghi/daisyui", tag: "v5.5.20", sparse: "packages/bundle", app: false, compile: false} in mix.exs | A git dep that lands in deps/daisyui/packages/bundle/; there is no npm |
| Heroicons | {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", ...} | SVGs in deps/heroicons/optimized, turned into hero-* classes by assets/vendor/heroicons.js |
| Entry CSS | assets/css/app.css | All Tailwind and daisyUI configuration |
| Dev watcher | tailwind: {Tailwind, :install_and_run, [:my_app, ~w(--watch)]} in config/dev.exs | Rebuilds on save |
| Aliases | assets.setup, assets.build (compile, tailwind my_app, esbuild my_app), assets.deploy (tailwind my_app --minify, esbuild my_app --minify, phx.digest) | |
@plugin "daisyui/packages/bundle/daisyui" resolves because the tailwind profile sets env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}, so bare imports are looked up in deps/ and _build/<env>/. To upgrade daisyUI, bump the tag: and run mix deps.update daisyui. To upgrade Tailwind, bump version: in config.exs and run mix tailwind.install. Apps generated by earlier 1.8.x releases may vendor the plugin as assets/vendor/daisyui.js and daisyui-theme.js and point @plugin at ../vendor/... instead. Check which form your app uses before you edit anything.
app.css, annotated
@import "tailwindcss" source(none); /* turn OFF automatic source detection */
@import "phoenix-colocated/my_app/colocated.css"; /* LiveView ColocatedCSS output (in _build) */
@source "../css";
@source "../js"; /* classes toggled from JS */
@source "../../lib/my_app_web"; /* every HEEx template and component */
@source "../../_build/dev/phoenix-colocated/my_app/*/"; /* dev reload of colocated CSS */
@plugin "../vendor/heroicons"; /* hero-* classes */
@plugin "daisyui/packages/bundle/daisyui" { themes: false; } /* components, no built-in themes */
@plugin "daisyui/packages/bundle/daisyui-theme" { name: "dark"; prefersdark: true; /* --color-* */ }
@plugin "daisyui/packages/bundle/daisyui-theme" { name: "light"; default: true; /* --color-* */ }
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
- Why the
@sourcelist matters:source(none)means Tailwind scans exactly these paths and nothing else. Classes that live inlib/my_app/(for example, a helper module that returns class strings), in a component library underdeps/, or in a sibling umbrella app produce no CSS until you add a line for them. Write it relative toapp.css, as in@source "../../deps/my_ui_kit/lib";. Don't dropsource(none)to "fix" this: automatic detection starts from the project root. - Don't delete the
phoenix-colocatedimport. The file exists only aftermix compile. That is whyassets.buildstarts withcompile, and why the release Dockerfile runsmix compilebeforemix assets.deploy. If you runmix tailwind my_appon a fresh checkout, it fails withCan't resolve 'phoenix-colocated/my_app/colocated.css'. - The loading variants style elements while an event is in flight:
<.button class="btn btn-primary phx-submit-loading:opacity-60">. dark:followsdata-theme="dark", notprefers-color-scheme. The theme script sets that attribute for system-dark users too, sodark:works in both cases, but semantic daisyUI colors usually makedark:unnecessary.- To add your own CSS, use plain rules or
@layer components { ... }below the generated block.
Dynamic class names
Tailwind reads the source files as plain text and generates CSS only for complete class tokens it finds. It never evaluates Elixir.
<!-- epg:bad --><span class={"badge badge-#{@status}"}>{@status}</span>
<div class={"text-#{@color}-600"}>...</div>
Neither badge-published nor text-teal-600 ends up in the built CSS (verified with Tailwind 4.3.3). The markup ships unstyled, and nothing warns you.
defmodule MyAppWeb.StatusComponents do
use Phoenix.Component
# Full class names written out, so the scanner sees every one.
@status_classes %{
draft: "badge-ghost",
published: "badge-success",
archived: "badge-neutral"
}
attr :status, :atom, required: true, values: Map.keys(@status_classes)
def status_badge(assigns) do
assigns = assign(assigns, :status_class, Map.fetch!(@status_classes, assigns.status))
~H"""
<span class={["badge", @status_class]}>{Phoenix.Naming.humanize(@status)}</span>
"""
end
end
- Conditional classes use the list form:
class={["btn", @active && "btn-active", @size == :sm && "btn-sm"]}.falseandnilentries are dropped. - Icon names follow the same rule:
name={"hero-#{@icon}"}generates nothing unless that exacthero-…string also appears somewhere in the scanned sources. Map to full names. - When the values really come from data (user-chosen colors stored in the DB), safelist them in
app.csswith@source inline("text-{red,green,blue}-600");. Brace expansion is supported. For arbitrary values, validate them (for example against~r/\A#[0-9a-f]{6}\z/i) and set a CSS variable,style={"--accent: #{@hex}"}, that a class likebg-(--accent)reads.
daisyUI themes and dark mode
The generated app ships two custom themes, defined in app.css with the daisyui-theme plugin. light has default: true and dark has prefersdark: true. All built-in daisyUI themes are disabled by themes: false. Each theme is a set of --color-* variables (base-100/200/300, base-content, primary, primary-content, secondary, accent, neutral, info, success, warning, error, each with a -content pair), plus radius, size, border and depth tokens. To restyle the app, edit those variables, not the components.
How the toggle works (in root.html.heex and Layouts.theme_toggle/1):
- An inline script in
<head>setsdata-themeon<html>before first paint. It readslocalStorage["phx:theme"]and falls back to"system", which followsprefers-color-scheme. It also setsdata-theme-source="system"or"user". - Each toggle button is
phx-click={JS.dispatch("phx:set-theme")}with adata-phx-theme="system|light|dark"attribute. The script listens forphx:set-themeand readse.target.dataset.phxTheme. - The script returns early if
<html>already hasdata-theme. To use a theme stored on the user, render it from the server inroot.html.heex:<html lang="en" data-theme={@current_scope && @current_scope.user.theme}>, wherethemeis a field you add to the user and isnilfor "system". Anilvalue omits the attribute, so the script takes over. - To add a theme, either add another
@plugin "daisyui/packages/bundle/daisyui-theme" { name: "brand"; ... }block or enable a built-in one (themes: corporate;), then add a button withdata-phx-theme="brand". Don't enable the built-inlight/darkthemes; their names collide with the custom ones.dark:utilities only match a theme nameddark.
Semantic classes to reach for:
| Need | daisyUI classes |
|---|---|
| Page and surfaces | bg-base-100 (page), bg-base-200 / bg-base-300 (raised or sunken), text-base-content, text-base-content/70 (muted) |
| Brand and status colors | bg-primary text-primary-content, text-error, bg-success text-success-content (always pair a background with its -content color) |
| Buttons | btn + btn-primary / btn-error / btn-ghost / btn-outline / btn-soft; sizes btn-sm, shapes btn-square, btn-circle |
| Form controls | input, select, textarea, checkbox, toggle, fieldset, label; error state input-error / select-error / textarea-error |
| Feedback | alert + alert-info / alert-success / alert-warning / alert-error, badge badge-*, loading loading-spinner, toast, skeleton |
| Layout and data | card card-body card-title card-actions, navbar, menu, table table-zebra, list list-row, tabs tab tab-active, modal modal-box modal-action, dropdown, join, divider, stat |
If the project's AGENTS.md says to hand-write Tailwind components instead of using daisyUI components (the 1.8 generator writes that line), follow it for components. Keep using the semantic color tokens anyway, because they are what makes dark mode work.
core_components and Layouts
| Component | Key attrs and slots | Notes |
|---|---|---|
| <Layouts.app> | flash (required), current_scope (default nil), inner block | Layouts is aliased in html_helpers. It renders the navbar, <main> and <.flash_group> |
| <.input> | field={@form[:x]}, type, label, options + prompt (select), multiple, class, error_class, errors; global :rest takes aria-*, phx-*, data-* and the listed HTML attrs | Shows field.errors only when used_input?(field) is true. Translates errors via translate_error/1. Types: text-likes, select, textarea, checkbox, hidden; radio is not supported |
| <.button> | variant (values: ~w(primary); default is btn-primary btn-soft), class, navigate/patch/href | Renders <.link> when given navigate/patch/href, otherwise <button> |
| <.icon> | name (hero-* only), class (default "size-4") | Renders an empty <span> styled with a CSS mask, so color is currentColor and the size comes from size-* |
| <.header> | inner block (the <h1>), subtitle slot, actions slot | Each <.header> renders an <h1>, so use one per page |
| <.table> | id, rows (list or @streams.x), row_click, row_id, row_item; col slot with label, action slot | Given a stream, it sets phx-update="stream" and derives row_id for you |
| <.list> | item slot with title | Read-only detail view |
| <.flash> | kind (:info / :error), flash, title | <.flash_group> lives in Layouts and renders only :info and :error. A put_flash(socket, :warning, ...) never shows unless you add a kind |
A generated-style page, using a stream and a form:
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Listing Posts
<:subtitle>Everything you've written.</:subtitle>
</.header>
<.table
id="posts"
rows={@streams.posts}
row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}
>
<:col :let={{_id, post}} label="Title">{post.title}</:col>
<:col :let={{_id, post}} label="Status"><.status_badge status={post.status} /></:col>
<:action :let={{_id, post}}>
<.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
</:action>
</.table>
<.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:status]} type="select" label="Status" options={@status_options} prompt="Choose…" />
<.input field={@form[:published]} type="checkbox" label="Published" />
<.button phx-disable-with="Saving..." variant="primary">Save</.button>
</.form>
</Layouts.app>
The generated index also puts a New Post <.button variant="primary" navigate={...}> in the header's actions slot. For the form's LiveView side (to_form, validate, errors), see the phoenix-liveview-essentials skill.
Extending core_components
Edit lib/my_app_web/components/core_components.ex directly. It is your code, not a dependency. These three changes cover most needs.
More button variants, plus type. Keep the map of full class strings (rule 3) and keep values: in sync, so a typo like variant="dangr" is a compile-time warning:
attr :rest, :global, include: ~w(href navigate patch method download name value disabled type)
attr :class, :any
attr :variant, :string, values: ~w(primary danger ghost)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{
"primary" => "btn-primary",
"danger" => "btn-error",
"ghost" => "btn-ghost",
nil => "btn-primary btn-soft"
}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>{render_slot(@inner_block)}</.link>
"""
else
~H"""
<button class={@class} {@rest}>{render_slot(@inner_block)}</button>
"""
end
end
Errors that screen readers announce. The generated <.input> wraps the control in its <label>, so the label is announced. The error <p> isn't connected to the field, though. In the catch-all text-input clause, change the <input> and the error list like this (and do the same in the select and textarea clauses):
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[@class || "w-full input", @errors != [] && (@error_class || "input-error")]}
aria-invalid={@errors != [] && "true"}
aria-describedby={@errors != [] && "#{@id}-errors"}
{@rest}
/>
<div :if={@errors != []} id={"#{@id}-errors"}>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
A false attribute value is omitted from the rendered HTML. This relies on @id, which field= always provides; when you use name= without field=, pass an id too.
A new flash kind. Add :warning to flash/1's values:, add an @kind == :warning && "alert-warning" entry to its class list, and add <.flash kind={:warning} flash={@flash} /> to Layouts.flash_group/1.
For a brand-new component, put it in core_components.ex (or in a new module under components/ that you import in html_helpers). Give it attr/slot declarations with doc:, and a :global rest attr so callers can pass id, aria-* and phx-*.
Accessibility: what you get vs. what's on you
| Already done by the generated code | On you |
|---|---|
| <.input> wraps the control in its <label for=...> | Always pass label, or aria-label when the visual design has none. A placeholder disappears on input and isn't a reliable name |
| <.flash> has role="alert"; flash_group is aria-live="polite"; the close button has aria-label | Name icon-only buttons: <.button navigate={~p"/posts"} aria-label="Back to posts"><.icon name="hero-arrow-left" /></.button>. The generated show.ex back button and the three theme_toggle buttons have no name, so add one |
| <.table> has a sr-only "Actions" header; the generated index keeps a sr-only Show link because row_click puts phx-click on <td>, which a keyboard can't reach | Keep a real <.link> in every clickable row. Never put phx-click on a div/span without also adding role, tabindex and key handling; use <button type="button"> instead |
| Decorative arrows in the layout use aria-hidden="true" | Radio groups (<.input> doesn't render radios): write <fieldset><legend>…</legend> with a <label> per <input type="radio"> |
| | Modals and menus: wrap the content in <.focus_wrap id="…"> (from Phoenix.Component), save focus with JS.push_focus(), move it in with JS.focus_first(to: "#…"), restore it with JS.pop_focus(), or use a native <dialog class="modal">. 1.8's core_components has no modal |
| | Contrast: pair every bg-<color> with its text-<color>-content, and check custom theme variables in both themes |
| | Respect reduced motion: motion-safe:animate-spin, as the generated flash does |
Common mistakes
| Wrong | Right |
|---|---|
| A tailwind.config.js with content: [...] | @source lines in app.css (Tailwind v4 ignores the JS config unless you load it with @config) |
| Component classes defined in lib/my_app/ui.ex and missing in the browser | Add @source "../../lib/my_app/ui.ex"; (or the directory) |
| class={"bg-#{@color}-500"} | A map or function returning "bg-red-500" etc., or @source inline(...) |
| bg-white text-gray-900 on cards | bg-base-100 text-base-content (works in dark mode) |
| <.button class="w-full"> | <.button class="btn btn-primary w-full">, or a variant |
| <.button type="button"> warning breaking mix precommit | Add type to include: in button/1 |
| <.icon name="hero-x-mark" aria-hidden="true" /> (warns; attribute dropped) | It's an empty span, so nothing to hide. Name the button instead |
| A second <.flash_group> inside a page already wrapped in Layouts.app | One per page. A second group shows every flash twice and duplicates the flash-group / client-error / server-error ids |
| Forgetting flash={@flash} on <Layouts.app> | flash is required: true on app/1. Leaving it out is a compile warning, and the layout has nothing to render flashes from |
| Running mix tailwind my_app in CI before compiling | mix assets.build (compiles first) or mix compile && mix assets.deploy |
| An empty phx-update="stream" container with no message | Add <div id="posts-empty" class="hidden only:block">No posts yet</div> as the first child (every child of a stream container needs an id) |