Three everyday things, done with Phoenix's own tools: Tailwind for icons,
put_flash/3 for messages, and Gettext for languages. Xenia adds no API
for any of them. What Xenia brings is the last step: getting the same
language into your JavaScript islands.
The examples app shows each one, with a page and a browser test:
/icons, /notifications, /languages, and /settings with all three
together.
Icons
An icon is a CSS class: <.icon name="lucide-save" class="size-5" />.
Tailwind builds it into your app.css from the icon's SVG, so nothing is
fetched at runtime, and only the icons your templates name end up in the
file.
Heroicons, already in Phoenix
A new Phoenix app ships Heroicons: hero-x-mark,
hero-check-circle-solid, hero-arrow-path-mini. The plugin is
assets/vendor/heroicons.js, and <.icon> is in core_components.ex.
More icon sets, from Iconify
Iconify publishes over 200 icon sets as npm packages of JSON. Lucide and Material Design Icons, for example:
1. Install the sets you want:
npm install --save-exact @iconify-json/lucide @iconify-json/mdi --prefix assets
2. Add the plugin. Copy assets/vendor/iconify.js from the examples
app into your assets/vendor/. It has about 40 lines of code and works like Phoenix's
heroicons.js: each <set>-<icon> class becomes a CSS mask with the
icon's SVG, filled with currentColor.
3. List the sets in assets/css/app.css, next to the heroicons plugin:
@plugin "../vendor/iconify" {
sets: lucide, mdi;
}4. Let <.icon> take any icon. Phoenix's generated icon/1 only
accepts hero- names. Replace it with one clause:
attr :name, :string, required: true
attr :class, :any, default: "size-4"
attr :label, :string, default: nil
def icon(assigns) do
~H"""
<span
class={[@name, @class]}
role={@label && "img"}
aria-label={@label}
aria-hidden={is_nil(@label) && "true"}
/>
"""
endWhy not Iconify's own @iconify/tailwind4 plugin? It's an ES module, and
the standalone Tailwind binary Phoenix uses can't load it (it reports
"Cannot find module"). The JSON packages load fine.
Names
The class is the set's prefix and the icon's name: lucide-save,
lucide-trash-2, mdi-check-circle, hero-x-mark. Find names on
icon-sets.iconify.design (shown there
as lucide:save) and heroicons.com.
Size and color work like text: size-4, size-8, text-success,
text-base-content/60. Because icons are filled with the text color, they
follow light and dark themes on their own.
The asset build
Tailwind finds icons by reading your templates for class names, exactly as it does for every other class. So:
- Write names in full:
name="lucide-save". A name built at runtime,name={"lucide-" <> @kind}, isn't in any file Tailwind reads, so it isn't in the CSS. Map such values to full names in code:name={if @ok, do: "lucide-circle-check", else: "lucide-circle-alert"}. - Check a production build once you've set it up:
MIX_ENV=prod mix assets.deploy, then look for.lucide-save{inpriv/static/assets/css/app.css.
Accessibility
- Icons are decorative by default:
aria-hidden="true". The button or text next to the icon says what it means. - A button with only an icon needs a name:
<button aria-label="Delete"><.icon name="lucide-trash-2" /></button>. - An icon that means something on its own gets
label:<.icon name="mdi-check-circle" label="Saved" />becomesrole="img"with that name, for a status shown without text.
The icons keep their own licenses: Lucide is ISC, Material Design Icons is Apache 2.0, Heroicons is MIT.
Notifications
Use Phoenix's flash. It already does what a notification needs: a message from the server, shown on the page, dismissed with a click.
def handle_event("save", _params, socket) do
case Documents.save(socket.assigns.document) do
{:ok, _document} ->
{:noreply, put_flash(socket, :success, gettext("Document saved successfully"))}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, gettext("Could not save document"))}
end
endSuccess, error and info
Phoenix's generated <.flash> renders :info and :error. A :success
message needs two small changes in your app:
- in
core_components.ex, add:successtoflash/1'skindvalues, withalert-successand an icon (lucide-circle-check); - in
layouts.ex, render<.flash kind={:success} flash={@flash} />inflash_group/1.
put_flash itself accepts any key; it's only the renderer that has to know
it. The examples app also gives errors role="alert" and the others
role="status", so screen readers interrupt only for errors.
Stacked, newest first
flash_group/1 is one column at the top right, so several messages never
cover each other: a new one appears on top and pushes the older ones down.
Phoenix keeps one message per kind, so there are at most three (info,
success, error); a new message of a kind already shown replaces it, moves
it to the top, and restarts its countdown.
Closing by themselves, with a countdown
mix xenia.install gives a new app this flash: the success kind, the
stacking and the countdown. Phoenix's flash stays until it's clicked; this
one can also close by itself: a line around the message's border runs out,
then the message closes. Hovering the message, or focus inside it, pauses
the countdown. Errors never close by themselves: the user may need time to
read them.
<%!-- layouts.ex: info and success close after 5 s, rounded corners --%>
<.flash_group flash={@flash} />
<.flash_group flash={@flash} shape="sharp" duration={8000} />
<.flash_group flash={@flash} shape="pill" duration={nil} /> <%!-- no countdown --%>How it's built, all in your app:
flash/1takesduration(milliseconds, ornil) andshape(rounded, the theme's radius;sharp;pill). With a duration, it draws an SVG<rect>over the message's border.- The countdown is CSS (in
app.css): the rect's stroke runs out over--flash-duration, so pausing on hover is one rule (animation-play-state: paused). Withprefers-reduced-motion, the line isn't drawn, but the message still closes on time. - A small colocated hook in
core_components.ex(<script :type={Phoenix.LiveView.ColocatedHook}>) does the rest. When the animation ends, it runs the message's ownphx-click, which clears the flash on the server and hides it. When a message appears or gets new text, it moves it to the top of the stack. Nothing to add toapp.js.
Copy flash/1, flash_group/1 and the "flash countdown" block of
app.css from the examples app.
Failures
Show a plain message, and log the details. An exception's message can hold a database address, a file path or a token:
{:error, reason} ->
Logger.error("Saving the document failed: #{inspect(reason)}")
{:noreply, put_flash(socket, :error, gettext("Could not save document"))}Flash messages are text: HEEx escapes them, so a message containing what a user typed can't inject HTML.
Translated messages
gettext("...") runs when you call put_flash, in the LiveView's process,
so the message is in the user's language (see Languages, below).
During navigation
put_flash, thenpush_navigate: the message shows on the next page.- A plain link to another page starts that page without the old message.
- A click on a message clears it (
lv:clear-flash).
Xenia doesn't turn JavaScript errors into notifications. For an island that
breaks, use its <:error> slot and on_crash event instead.
Languages
Gettext is the only translation store. Phoenix translates, and passes the words to JavaScript islands as props.
Set up Gettext
A Phoenix app made without --no-gettext has all of this already.
# mix.exs
{:gettext, "~> 1.0"}
# lib/my_app_web/gettext.ex
defmodule MyAppWeb.Gettext do
use Gettext.Backend, otp_app: :my_app
end
# lib/my_app_web.ex, in html_helpers
use Gettext, backend: MyAppWeb.Gettext
# config/config.exs
config :my_app, MyAppWeb.Gettext, default_locale: "en", locales: ~w(en de)Add a language
mix gettext.extract # finds every gettext(...) call
mix gettext.merge priv/gettext --locale de # creates de/LC_MESSAGES/default.po
Translate each msgstr in priv/gettext/de/LC_MESSAGES/default.po. Later,
mix gettext.extract --merge adds new messages to every language.
Translations in HEEx, and switching without a reload
{gettext("Save")} in a template works, with one catch when users switch
language on a live page: LiveView only re-renders what depends on
assigns. An expression without assigns is rendered once, so it stays in
the old language until the page reloads.
Keep the words a page shows in an assign, and compute it again after a switch:
def mount(_params, _session, socket), do: {:ok, assign_texts(socket)}
defp assign_texts(socket) do
assign(socket, t: %{heading: gettext("Languages"), save: gettext("Save")})
end
def handle_event("set_locale", %{"locale" => locale}, socket),
do: {:noreply, socket |> MyAppWeb.Locale.put(locale) |> assign_texts()}<h1>{@t.heading}</h1>
<button>{@t.save}</button>Text with a value in it can take @locale as an argument, so it re-renders
on a switch too: {saves_text(@saves, @locale)}.
If your users pick a language once (in their profile, say), and a page
reload is fine, gettext straight in templates is all you need.
Passing translated text into a Xenia island
Translate on the server, and pass the words as props:
<.xenia
id="stepper"
adapter="Stepper"
props={%{labels: %{reset: gettext("Reset"), total: gettext("Total")}}}
/>When the language changes, the props change, and Xenia calls the adapter's
prop handlers, as for any prop. The island isn't rebuilt, so its own state
(a count, a selection, an open editor) stays. In the adapter, put the words
in with textContent, not innerHTML: translations are data.
For numbers and dates, publish the locale once, in your layout, and let islands format for it:
<Xenia.Provider.provider name="locale" value={@locale} />export const stepper = {
consumes: ["locale"],
props: {
locale(instance, locale) { instance.money = new Intl.NumberFormat(locale, ...) },
},
}Switching language in an app
Gettext keeps the locale per process, and a LiveView app has several:
The first page load is an HTTP request: a plug reads a
localecookie, callsGettext.put_locale/2, and puts the locale in the session.Each LiveView sets it again in
on_mount, from its connect params, or from the session on the first render.A switch calls
Gettext.put_locale/2in the LiveView's process, assigns@locale, and sends apush_eventso the browser stores the cookie.The browser sends the cookie's value in the LiveSocket's connect params. LiveSocket calls a
paramsfunction on every LiveView join, so live navigation to the next page uses the new language too:const locale = () => document.cookie.match(/(?:^|; )locale=(\w+)/)?.[1] window.addEventListener("phx:locale", ({detail}) => { document.cookie = `locale=${detail.locale}; path=/; max-age=31536000; SameSite=Lax` }) new LiveSocket("/live", Socket, {params: () => ({_csrf_token: csrfToken, locale: locale()})})
The examples app's XeniaExamplesWeb.Locale does all four in about 70
lines: the plug, on_mount, put/2 and a language selector.
What Xenia doesn't do
Xenia doesn't extract or translate strings written in JavaScript. Words an
island shows come from Phoenix, as props. That keeps one translation store,
in the .po files your team already edits.