Packages

Native Phoenix LiveView UI components — server-rendered SVG charts and more, themeable via CSS variables (Fluxon-compatible). No React, no JS chart libraries.

Current section

Files

Jump to
lantern_ui README.md
Raw

README.md

# 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`](https://github.com/go9/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`, and
`LanternUI.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`, and
`LanternUI.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`, and `LanternUI.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`, and
`LanternUI.Components.ScrollArea`.
- **Dense-app primitives:** `LanternUI.Components.ListRow`,
`LanternUI.Components.GroupBand`, `LanternUI.Components.Inspector`,
`LanternUI.Components.StateGlyph`, and `LanternUI.Components.SidePanel`.
Icon actions, rings, segmented controls, and property rows fold into
`Button` (`label`/`kbd`), `Progress` (`shape="ring"`), `Tabs`
(`tabs_list variant="segmented"`), and `DescriptionList` (`layout="dense"`)
(see [Dense-app primitives](docs/dense-app.md)).
- **Theming and layout:** `LanternUI.Components.Theme` supplies the theme
component and tokens used by the component families.
- **Charts:** `LanternUI.Charts` provides area, bar, line, and
sparkline components; `LanternUI.Charts.Geometry` provides
pure chart calculation helpers.
- **Chat:** [Chat Kit guide](https://github.com/go9/lantern-ui/blob/main/docs/chat-kit.md) covers
`LanternUI.Components.Avatar`, `LanternUI.Components.Message`, and
`LanternUI.Components.MessageScroller` composition.
See the [published HexDocs](https://hexdocs.pm/lantern_ui) 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/`](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
```elixir
def deps do
[{:lantern_ui, "~> 0.8"}]
end
```
## Usage
```heex
<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.
```heex
<.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
`div`s; 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:
```heex
<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`:
```heex
<.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.
```heex
<.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`:
```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](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:
```heex
<.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:
```heex
<.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:
```heex
<.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>
```
```elixir
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 any
`lui-*` 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:
```css
/* 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
the `lantern_ui.css` header). 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](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
```bash
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](LICENSE).