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.
- Forms and input:
LanternUI.Components.Form,LanternUI.Components.Button,LanternUI.Components.Autocomplete,LanternUI.Components.Select,LanternUI.Components.Checkbox,LanternUI.Components.Radio,LanternUI.Components.Switch,LanternUI.Components.Slider,LanternUI.Components.Textarea,LanternUI.Components.ColorInput,LanternUI.Components.DatePicker,LanternUI.Components.DatetimeField, andLanternUI.Components.Calendar. - Overlays and navigation:
LanternUI.Components.Modal,LanternUI.Components.AlertDialog,LanternUI.Components.Sheet,LanternUI.Components.Popover,LanternUI.Components.Tooltip,LanternUI.Components.Dropdown,LanternUI.Components.Menu,LanternUI.Components.Tabs,LanternUI.Components.Accordion,LanternUI.Components.Command,LanternUI.Components.Layout,LanternUI.Components.Breadcrumb,LanternUI.Components.Navlist, andLanternUI.Components.Pagination. - Data and display:
LanternUI.Components.Table,LanternUI.Components.DataTable,LanternUI.Components.ResourceList,LanternUI.Components.Stat,LanternUI.Components.Badge,LanternUI.Components.Avatar,LanternUI.Components.Icon,LanternUI.Components.Separator,LanternUI.Components.Timeline,LanternUI.Components.Waterfall, andLanternUI.Components.LogView. - Feedback and status:
LanternUI.Components.Alert,LanternUI.Components.Toast,LanternUI.Components.EmptyState,LanternUI.Components.Progress,LanternUI.Components.Meter,LanternUI.Components.Loading,LanternUI.Components.Skeleton, andLanternUI.Components.ScrollArea. - Dense-app primitives:
LanternUI.Components.ListRow,LanternUI.Components.GroupBand,LanternUI.Components.Inspector,LanternUI.Components.StateGlyph, andLanternUI.Components.SidePanel. Icon actions, rings, segmented controls, and property rows fold intoButton(label/kbd),Progress(shape="ring"),Tabs(tabs_list variant="segmented"), andDescriptionList(layout="dense") (see Dense-app primitives). - Theming and layout:
LanternUI.Components.Themesupplies the theme component and tokens used by the component families. - Charts:
LanternUI.Chartsprovides area, bar, line, and sparkline components;LanternUI.Charts.Geometryprovides pure chart calculation helpers. - Chat: Chat Kit guide covers
LanternUI.Components.Avatar,LanternUI.Components.Message, andLanternUI.Components.MessageScrollercomposition.
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/:
lantern-ui-components— author or extend library components.lantern-migration— migrate a Phoenix application onto LanternUI.phoenix-page-design— design consistent LiveView pages with LanternUI.lantern-recipes— copy these before writing any list/rail/inbox/overview page.
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:
lantern_ui.css— component styles, always import it when using anylui-*component (button, pickers, modal, dropdown, …). Charts don't need it.lantern_ui_theme.css— the optional default theme (tokens, light/dark, density modes).
Pick per host:
-
Standalone / public? Import both:
/* assets/css/app.css */@import "../../deps/lantern_ui/priv/static/lantern_ui_theme.css";@import "../../deps/lantern_ui/priv/static/lantern_ui.css"; -
Using Fluxon (or any system defining design tokens)? Import only the component styles and bridge
--lantern-*onto your tokens (full example in thelantern_ui.cssheader). Charts need neither — they inherit through their built-in fallbacks. -
Recolor by setting any
--lantern-*variable yourself.
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.