LanternUI

Native Phoenix LiveView UI components — server-rendered SVG charts (and more over time), themeable via CSS variables. No React, no JavaScript charting library.

Part of the lantern family: lantern is an embeddable Postgres table viewer; lantern_ui is the UI component set.

What's here

Public component families are grouped below. Each module's generated docs list the complete function and attribute surface.

See the published HexDocs for the released API.

Geometry is computed in Elixir, so charts re-render through normal LiveView assigns. Client JS lives in the committed priv/static/lantern_ui_hooks.js bundle (esbuild; consumers do not need a JS toolchain).

Agent skills

Reusable guidance for agents working with LanternUI lives in skills/:

Import these files from this public repository rather than copying private, project-specific variants.

Installation

def deps do
[{:lantern_ui, "~> 0.8"}]
end

Usage

<LanternUI.Charts.area_chart
id="price-history"
series={[%{date: "2024-01-01", value: 24.5}, %{date: "2024-02-01", value: 27.1}]}
value_format={:currency}
height={250}
/>
<LanternUI.Charts.sparkline id="trend" series={[3, 5, 4, 6, 8, 7, 9]} />
<LanternUI.Charts.bar_chart
id="sales"
series={[%{label: "Q1", value: 42}, %{label: "Q2", value: 31}]}
/>
<LanternUI.Charts.line_chart
id="pod-cpu"
series={[
%{label: "web-1", color: "var(--color-primary)",
points: [{~U[2024-11-20 14:00:00Z], 0.25}, {~U[2024-11-20 14:05:00Z], 0.31}]},
%{label: "web-2", points: [{~U[2024-11-20 14:00:00Z], 0.18}, {~U[2024-11-20 14:05:00Z], 0.22}]}
]}
value_format={&"#{&1} cores"}
/>

line_chart series: a list of %{label, color, points: [{datetime, number}]} (color optional; points accept {datetime, value} tuples or %{time, value} maps; datetime = DateTime/NaiveDateTime/Date/ISO-8601 string).

area_chart series: a list of %{date: iso8601 | Date, value: number}.

Summary stats

stat_card/1 exposes the same compact metric presentation used by the data table overview. Use it for a single metric, or use the slot-driven stat_grid/1 for responsive groups. The grid wraps cards from the same 9.5rem basis, so one, two, or many cards fill wide layouts and reflow at narrow widths without caller breakpoints.

<.stat_card label="Open orders" value={42} icon="hero-inbox" />
<.stat_grid aria-label="Order summary">
<:stat label="Open" value={42} />
<:stat label="Shipped" href={~p"/orders?status=shipped"}>{128}</:stat>
</.stat_grid>
<.stat_grid aria-label="Store health">
<:stat label="Products" value={@product_count} />
<:stat label="Low stock" value={@low_stock_count} subtitle="Needs attention" />
<:stat label="Orders" value={@order_count} />
<:stat label="Revenue" value={@formatted_revenue} />
</.stat_grid>

stat_card/1 requires label and value. Every stat_grid/1 :stat slot requires label; provide its metric through either the value attribute or inner slot content, as shown above. subtitle, host heroicon icon, href, and class are optional. Cards without href render as non-interactive divs; cards with href render as links. Calculations, formatting, trends, charts, and navigation state remain caller-owned.

Skeleton placeholders

Skeleton.skeleton/1 is decorative and needs no JavaScript or Tailwind. Its bundled lui-skeleton CSS makes the default placeholder full width and one line high; class or style can supply application-specific geometry. Mark the surrounding loading region busy and give that region an accessible name:

<section aria-busy="true" aria-label="Loading profile">
<LanternUI.Components.Skeleton.skeleton
class="profile-avatar-placeholder"
style="width: 3rem; height: 3rem; border-radius: 9999px;"
/>
<LanternUI.Components.Skeleton.skeleton style="width: 12rem;" />
<LanternUI.Components.Skeleton.skeleton style="height: 8rem; margin-top: 1rem;" />
</section>

Each skeleton renders with aria-hidden="true". Its pulse automatically stops when the user enables reduced motion.

Accordion

accordion/1 and accordion_item/1 mirror Fluxon 2.3.1, so an existing call can migrate by changing only use Fluxon to use LanternUI:

<.accordion id="faq" prevent_all_closed animation_duration={300}>
<.accordion_item id="shipping" expanded>
<:header>Where do you ship?</:header>
<:panel>Worldwide.</:panel>
</.accordion_item>
<.accordion_item id="returns" icon={false}>
<:header class="font-semibold">What is the return window?</:header>
<:panel class="prose">Thirty days.</:panel>
</.accordion_item>
</.accordion>

Both ids are optional and generated when omitted. multiple allows several panels open; prevent_all_closed keeps one open. The panel remains in the DOM and is hidden when collapsed so ARIA relationships stay valid. The indicator uses animation_duration; prefers-reduced-motion: reduce disables its transition.

Destructive confirmations

Use alert_dialog/1 instead of hand-building destructive modal semantics. Its four slots are required; cancel is focused first, backdrop clicks are ignored, and Escape closes while restoring focus to the trigger.

<.button phx-click={LanternUI.open_dialog("delete-project")}>Delete…</.button>
<.alert_dialog id="delete-project">
<:title>Delete this project?</:title>
<:description>This permanently deletes the project and its data.</:description>
<:cancel>
<.button phx-click={LanternUI.close_dialog("delete-project")}>Cancel</.button>
</:cancel>
<:action>
<.button color="danger" phx-click="delete-project">Delete project</.button>
</:action>
</.alert_dialog>

The application owns action and cancel events. Use an alert dialog only for an important, usually irreversible confirmation, not for informational content.

JS hooks (mandatory for Accordion and interactive components)

Register the complete LanternHooks bundle whenever Accordion is used. Without LanternAccordion, headers do not toggle, keyboard navigation does not run, and prevent_all_closed cannot be enforced. Interactive components, including alert_dialog through LanternModal, ship their hooks in the same bundle. area_chart uses ChartHover, and line_chart uses LineHover; the single import below registers all shipped hooks. In assets/js/app.js:

import LanternHooks from "../../deps/lantern_ui/priv/static/lantern_ui_hooks.js"
let Hooks = { ...LanternHooks /* , ...yourOtherHooks */ }
let liveSocket = new LiveSocket("/live", Socket, { params: {/* ... */}, hooks: Hooks })

The file you import is the esbuild bundle of assets/js/. Data-attribute behaviours (list keyboard nav, persist, collapse) install themselves on import — see docs/behaviours.md. Overlay panels (popover, dropdown, select, menu, autocomplete) are placed with @floating-ui/dom, inlined in the bundle so the import path stays deps/lantern_ui/priv/static/lantern_ui_hooks.js.

sparkline and bar_chart need no JavaScript. Accordion always requires the hook bundle above.

Autocomplete

autocomplete filters local options by default and keeps the selected value in a normal hidden form input:

<.autocomplete field={@form[:country]} options={@countries} clearable />

For remote data, the LiveView owns the result list. Set on_search; the hook pushes %{"query" => query} after search_threshold and debounce, displays its loading state, and stops loading when the patched options arrive:

<.autocomplete
field={@form[:user_id]}
options={@user_results}
on_search="search_users"
search_threshold={2}
debounce={250}
open_on_focus
clearable
>
<:option :let={{name, id}}>
<strong>{name}</strong> <small>#{id}</small>
</:option>
<:empty_state>No matching users</:empty_state>
</.autocomplete>

Options may be nested labelled groups. A Fluxon-style {label, children} is a group when children is a non-empty tuple list. Use {:group, label, children} for an empty group or scalar children; this explicit form preserves existing {label, list_value} options without ambiguity. inner_prefix, inner_suffix, outer_prefix, outer_suffix, header, and footer slots customize the surrounding states without moving search or selection ownership into LanternUI.

The animation, animation_enter, and animation_leave attrs are accepted as Fluxon compatibility no-ops. Like Lantern's modal, autocomplete motion is controlled by the bundled CSS and duration tokens.

Command palette

command is a ⌘K dialog: a modal combobox over a listbox of actions. It opens and closes through the same contract as modal — LanternUI.open_dialog/1 / close_dialog/1 (or the socket forms) — plus a global Meta/Ctrl+hotkey.

The component never filters. It renders exactly the items you give it and pushes the query upward, so the LiveView can answer from a database, a search index, or anything else:

<.command id="cmd-k" on_search="command_search" on_select="command_select">
<.command_group label="Tickets">
<.command_item :for={t <- @results} value={t.id}>
{t.title}
<:icon><.icon name="inbox" /></:icon>
<:description>{t.project}</:description>
<:shortcut>⏎</:shortcut>
</.command_item>
</.command_group>
<.command_empty :if={@results == []}>No matches for “{@query}”</.command_empty>
<:footer>↑↓ to navigate · ⏎ to select</:footer>
</.command>
def handle_event("command_search", %{"query" => query}, socket) do
{:noreply, assign(socket, query: query, results: MyApp.Search.run(query))}
end
def handle_event("command_select", %{"value" => id}, socket) do
{:noreply, push_navigate(socket, to: ~p"/tickets/#{id}")}
end

The LanternCommand hook pushes both events itself, so the palette renders no <form> and no phx-change / phx-submit, and its event names default to the command_* namespace. That matters when the palette is mounted in an app shell on every page: a global component with a generic phx-change="search" makes a host app's own element("form") and form[phx-change="search"] test selectors ambiguous. An item that carries its own phx-click is left alone — the hook does not also push on_select, so per-item bindings are never double-fired.

Keyboard: ↑/↓ move the highlight, Home/End jump, Enter activates, Escape closes. Focus is trapped in the panel while open and restored on close. Focus stays in the input (this is an APG combobox), so the highlighted item is published with aria-activedescendant rather than by moving DOM focus.

command_separator, command_shortcut, and a loading attr round out the anatomy. Set hotkey={nil} to drop the global shortcut, on_search={nil} / on_select={nil} to drop an event, and close_on_select={false} to keep the palette open after a choice.

Theming

Components read colors from CSS variables with chained fallbacks:

Purpose Variable chain
accent --lantern-accent → --color-primary-500 → #3b82f6
text --lantern-fg → --foreground → #111827
muted text --lantern-fg-muted → --foreground-softer → #6b7280
surface (tooltip) --lantern-surface → --background-base → #ffffff

Two stylesheets ship in priv/static:

Pick per host:

Compact type + grey roles. Dense chrome uses text-meta (11px), text-caption (12px), and text-mono-meta instead of text-[11px]. The four grey roles are text-foreground, text-foreground-soft, text-foreground-softest, and text-muted-foreground. text-foreground-softer is a deprecated alias of -soft. Full table and the consumer lint (mix lantern.lint) are in docs/scale.md.

Value formatting

area_chart and bar_chart accept value_format: :number (default), :currency (USD-style $ prefix), or a 1-arity function (number -> String.t()). Any other value is treated as :number. Custom-function output is rendered as plain text — it is HTML-escaped before it reaches the tooltip.

Development

mix test # Elixir: rendering, ARIA conformance, class merging
mix lantern.lint # optional: same check consumers run (this repo is mostly lui-* CSS)
npm run build # assets/js → priv/static/lantern_ui_hooks.js (commit the result)
npm test # JavaScript: the committed hooks bundle against jsdom

npm test needs npm install once; it runs node --test test/js/*.mjs, which mounts each hook against a jsdom document and drives it the way a user would. Two command bugs — the palette vanishing on the first keystroke, and events never reaching a LiveComponent — were invisible to the Elixir suite because both lived entirely in the hook, so interactive behaviour belongs in test/js/.

jsdom, esbuild, and @floating-ui/dom are dependencies of a private package.json used only to build and test this repo. The Hex package ships the already-bundled priv/static/lantern_ui_hooks.js, so consumers never need a JS toolchain. The previous “zero JS dependencies / unbundled ESM” goal was retired on 2026-09-20 — the public import path did not change.

License

MIT — see LICENSE.