Agent Skills: Phoenix UI: Tailwind v4, daisyUI and core_components

Use when styling Phoenix 1.8 UI — Tailwind v4 app.css (@source, @plugin, no tailwind.config.js), dynamic classes, daisyUI themes/dark mode, core_components (<.input>, <.button>, <.icon>), Layouts.app, flash, accessibility. Not for LiveView forms/lifecycle or JS hooks (use phoenix-liveview-essentials).

UncategorizedID: j-morgan6/elixir-claude-optimization/phoenix-tailwind-daisyui

Install this agent skill to your local

pnpm dlx add-skill https://github.com/j-morgan6/elixir-phoenix-guide/tree/HEAD/skills/phoenix-tailwind-daisyui

Skill Files

Browse the full folder contents for phoenix-tailwind-daisyui.

Download Skill

Loading file tree…

skills/phoenix-tailwind-daisyui/SKILL.md

Skill Metadata

Name
phoenix-tailwind-daisyui
Description
"Use when styling Phoenix 1.8 UI — Tailwind v4 app.css (@source, @plugin, no tailwind.config.js), dynamic classes, daisyUI themes/dark mode, core_components (<.input>, <.button>, <.icon>), Layouts.app, flash, accessibility. Not for LiveView forms/lifecycle or JS hooks (use phoenix-liveview-essentials)."

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

  1. Wrap every page in <Layouts.app flash={@flash}>, and pass current_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.
  2. Tailwind is configured in assets/css/app.css. There is no tailwind.config.js. Keep @import "tailwindcss" source(none), and add an @source line for every directory outside lib/my_app_web that contains class names. Tailwind scans only the listed paths.
  3. 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.
  4. Use daisyUI's semantic colors rather than raw palette colors. Write bg-base-100, text-base-content, btn-primary, text-error and the *-content pairs, not bg-white, text-gray-900 or text-red-600. The semantic colors switch with the light/dark theme; hard-coded colors don't.
  5. Use core_components.ex first, 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 its attr/slot declarations, including values: and required:.
  6. Passing class to <.button> or <.input> replaces the default classes; it doesn't add to them. <.button class="w-full"> loses btn. Pass the full class list (class="btn btn-primary w-full") or add a variant.
  7. Icons are <.icon name="hero-…"> with a literal name. The suffixes are none (outline 24), -solid, -mini and -micro. icon/1 accepts only name and class; any other attribute is a compile warning and isn't rendered.
  8. <.button> declares only href 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 fails mix precommit (compile --warnings-as-errors). Add type to the button's include: list.
  9. Accessibility you add yourself: a label on every <.input> (a placeholder is not a label), an aria-label on every icon-only button, <button> or <.link> for anything clickable (never phx-click on a div), 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 @source list matters: source(none) means Tailwind scans exactly these paths and nothing else. Classes that live in lib/my_app/ (for example, a helper module that returns class strings), in a component library under deps/, or in a sibling umbrella app produce no CSS until you add a line for them. Write it relative to app.css, as in @source "../../deps/my_ui_kit/lib";. Don't drop source(none) to "fix" this: automatic detection starts from the project root.
  • Don't delete the phoenix-colocated import. The file exists only after mix compile. That is why assets.build starts with compile, and why the release Dockerfile runs mix compile before mix assets.deploy. If you run mix tailwind my_app on a fresh checkout, it fails with Can'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: follows data-theme="dark", not prefers-color-scheme. The theme script sets that attribute for system-dark users too, so dark: works in both cases, but semantic daisyUI colors usually make dark: 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"]}. false and nil entries are dropped.
  • Icon names follow the same rule: name={"hero-#{@icon}"} generates nothing unless that exact hero-… 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.css with @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 like bg-(--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> sets data-theme on <html> before first paint. It reads localStorage["phx:theme"] and falls back to "system", which follows prefers-color-scheme. It also sets data-theme-source="system" or "user".
  • Each toggle button is phx-click={JS.dispatch("phx:set-theme")} with a data-phx-theme="system|light|dark" attribute. The script listens for phx:set-theme and reads e.target.dataset.phxTheme.
  • The script returns early if <html> already has data-theme. To use a theme stored on the user, render it from the server in root.html.heex: <html lang="en" data-theme={@current_scope && @current_scope.user.theme}>, where theme is a field you add to the user and is nil for "system". A nil value 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 with data-phx-theme="brand". Don't enable the built-in light/dark themes; their names collide with the custom ones. dark: utilities only match a theme named dark.

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) |

Phoenix UI: Tailwind v4, daisyUI and core_components Skill | Agent Skills