Copy these before writing any list, rail, inbox, or overview page in a lantern app. Never hand-roll rows, glyphs, rings, or rails.
Each recipe is one when-to-use line plus one HEEx block built only from lantern components (lantern classes and --lantern-* tokens are fine; no raw Tailwind utilities, no arbitrary text-[Npx] values). The HEEx is the same source the test suite renders (test/support/recipes/).
Origins are the flicker pages these shapes were extracted from:
- tickets list — orlando-umbrella/flicker#1404
- suggestions inbox — orlando-umbrella/flicker#1407
- project hub — orlando-umbrella/flicker#1408
Swap the fixture assigns (@ticket, @paths, …) for the host's LiveView assigns. String paths in the recipes compile without verified routes; prefer ~p in the app.
Page blocks
Whole pages that look finished, copied as one block. Each block is one when-to-use line plus one HEEx block built only from lantern components (same source as test/support/blocks/), and each renders in both the default theme and the shadcn preset — see test/fixtures/blocks_gallery/ for the screenshots.
Blocks inherit the host's body font and measure: the gallery fixture sets font-family: var(--lantern-font) on the body the way a host app does, stack/1 owns vertical rhythm, and only the page measure (max-width, centering) stays an inline style. No Tailwind utilities anywhere — the blocks render identically with or without a host utility layer. Screenshots live in test/fixtures/blocks_gallery/ (the HTML docs regenerate via mix test; only the JPEGs are committed).
- app_shell — use this when the page needs the full frame: sidebar nav, breadcrumb trail, and content in one shell.
- dashboard — use this when the page answers "where do we stand": stat cards, one chart, recent activity.
- index — use this when the page is a record list: flat table, filter chips with counts, search, pagination, row click.
- detail — use this when the page is one record: breadcrumb actions, body card, right inspector panel.
- settings — use this when the page edits preferences: stacked section cards, each with its own form and save row.
- form — use this when the page creates or edits one record: single card, inline validation errors, cancel/save footer.
- login — use this when the page signs someone in: centered card, one primary action, SSO second.
- destructive — use this when the page ends something: confirm dialog plus the empty, loading, and error states around it.
Fill list pages
When to use: The page body is a single data_table. Pass fill (and give the parent a bounded flex height, e.g. flicker's page_layout fill) so the rows area scrolls inside the table and pagination stays at the bottom of the viewport. List, cards, and table views all honour this; without fill a long list grows and pushes the pager below the fold.
Grouped tables and group headers are banned. A page that listed rows under tinted collapsible group headers becomes one flat list: a status column on each row, rows ordered by status then recency, and quick-filter chips or a tabs-free filter. Whatever the group header said (status name, count) must stay visible per row or in the filter chips with counts.
Linear-style list row
When to use: A dense issue row — priority glyph, id, status glyph, title, tags, progress ring, date — in a flat list.
Origin: flicker #1404 tickets list.
<.list_row
identifier={@ticket.identifier}
title={@ticket.title}
parent={@ticket.parent}
navigate={@ticket.href}
>
<:leading>
<.priority_glyph priority={@ticket.priority} />
<.status_glyph status={@ticket.status} />
</:leading>
<:meta>
<.badge size="sm">{@ticket.tag}</.badge>
<.progress shape="ring" completed={@ticket.completed} scope={@ticket.scope} size="sm" />
</:meta>
<:trailing>{@ticket.date}</:trailing>
</.list_row>Flat ticket list with toolbar
When to use: A ticket index as one flat data_table: a status column on each row (glyph + name), rows ordered by status then recency, a status filter with counts, and search. Title and actions live in the table header; no tabs, no group bands. Row click opens the record.
Origin: flicker #1404 tickets list.
<.data_table
id="tickets"
rows={@tickets}
meta={@meta}
path="/tickets"
title="Tickets"
fill
views={["list"]}
show_checkboxes={false}
search_field={:title}
data-lantern-list-nav
>
<:filter
field={:status}
label="Status"
options={[{"In progress (1)", "in_progress"}, {"To do (1)", "todo"}]}
prompt="All statuses"
/>
<:list_item :let={ticket}>
<.list_row
identifier={ticket.identifier}
title={ticket.title}
parent={ticket.parent}
navigate={ticket.href}
selected={ticket.selected}
data-lantern-list-item
>
<:leading>
<.status_glyph status={ticket.status} />
<span>{ticket.status |> Atom.to_string() |> String.replace("_", " ")}</span>
</:leading>
<:meta>
<.badge size="sm">{ticket.tag}</.badge>
</:meta>
<:trailing>{ticket.date}</:trailing>
</.list_row>
</:list_item>
</.data_table>Record page with inspector rail
When to use: A record page: breadcrumb actions hold the panel toggle, title plus a long body (LiveCode in flicker; the description card is the host slot), and a collapsible side panel of property rows.
Origin: flicker #1404 ticket show.
<.breadcrumb_bar id="ticket-crumb">
<.breadcrumb home="/" items={@crumbs} />
<:actions label="Toggle panel">
<.side_panel_toggle
id="ticket-panel-toggle"
panel_id="ticket-panel"
panel_key="ticket"
open={@panel_open}
kbd="]"
/>
</:actions>
</.breadcrumb_bar>
<.page_header title={@ticket.title} description={@ticket.identifier} />
<.card title="Description">
{@ticket.body}
</.card>
<.side_panel id="ticket-panel" open={@panel_open} aria-label="Ticket properties">
<.inspector aria-label="Ticket">
<.inspector_section title="Properties">
<.description_list layout="dense">
<:item label="Status">{@ticket.status}</:item>
<:item label="Priority">{@ticket.priority}</:item>
<:item label="Tags">
<.badge size="sm">{@ticket.tag}</.badge>
</:item>
</.description_list>
</.inspector_section>
</.inspector>
</.side_panel>Three-column inbox
When to use: Inbox · reading pane · properties. List on the left, selected record in the middle, inspector on the right.
Origin: flicker #1407 suggestions inbox.
<.card_grid>
<.card flush title={"Inbox · #{@inbox_count}"}>
<:actions>
<.button size="icon" variant="ghost" label="Filter" kbd="F">
<.icon name="funnel" />
</.button>
</:actions>
<.scroll_area label="Inbox" data-lantern-list-nav>
<.list_row
:for={item <- @inbox_items}
identifier={item.identifier}
title={item.title}
navigate={item.href}
selected={item.selected}
data-lantern-list-item
>
<:leading><.status_glyph status={item.status} /></:leading>
<:meta>
<.badge size="sm">{item.kind}</.badge>
</:meta>
</.list_row>
</.scroll_area>
</.card>
<.card title={@selected.title} description={@selected.meta}>
{@selected.body}
</.card>
<.card flush title="Properties">
<.inspector aria-label="Suggestion">
<.inspector_section title="Properties">
<.description_list layout="dense">
<:item label="Status">{@selected.status}</:item>
<:item label="Source">{@selected.source}</:item>
</.description_list>
</.inspector_section>
</.inspector>
</.card>
</.card_grid>Project overview
When to use: Hub header, a progress ring with a stats strip, then flat children. Do not invent a custom overview grid.
Origin: flicker #1408 project hub.
<.page_header title={@project.name} description={@project.summary}>
<:actions>
<.progress
shape="ring"
completed={@project.completed}
scope={@project.scope}
label="Progress"
>
{@project.completed} / {@project.scope}
</.progress>
</:actions>
</.page_header>
<.stat_grid>
<:stat label="Apps" value={@stats.apps} />
<:stat label="Databases" value={@stats.databases} />
<:stat label="Environments" value={@stats.environments} />
</.stat_grid>
<.card flush title="Tickets">
<.list_row
:for={child <- @children}
identifier={child.identifier}
title={child.title}
navigate={child.href}
>
<:leading><.status_glyph status={child.status} /></:leading>
<:trailing>{child.date}</:trailing>
</.list_row>
</.card>Icon toolbar with kbd hints
When to use: Icon-only actions with an accessible label and a keyboard hint in the tooltip. Used on the inbox thread chrome.
Origin: flicker #1407 suggestions inbox.
<.card>
<:actions>
<.button size="icon" label="Promote to ticket" kbd="P" variant="solid">
<.icon name="arrow-up-tray" />
</.button>
<.button size="icon" label="Snooze" kbd="H" variant="ghost">
<.icon name="clock" />
</.button>
<.button size="icon" label="Mark resolved" kbd="E" variant="ghost">
<.icon name="check" />
</.button>
<.button size="icon" label="More" variant="ghost">
<.icon name="ellipsis-horizontal" />
</.button>
</:actions>
Inbox thread
</.card>Capacity strip
When to use: N slots · busy · waiting · cost as lantern stats and badges. Do not hand-roll a metric row.
Origin: flicker #1408 project hub (factory capacity on the hub).
<.stat_grid>
<:stat label="Slots" value={@capacity.slots} />
<:stat label="Busy" value={@capacity.busy} />
<:stat label="Waiting" value={@capacity.waiting} />
<:stat label="Cost" value={@capacity.cost} />
</.stat_grid>
<.badge color="warning" size="sm">{@capacity.waiting} waiting</.badge>
<.badge color="success" size="sm">{@capacity.busy} busy</.badge>Block 1: app shell with sidebar and breadcrumb header
When to use: The page needs the full frame — brand, sidebar nav with groups and count badges, breadcrumb trail, and content — in one shell. Hosts with plain use LanternUI write <.app_shell>; the fixture below calls it fully qualified only because the test module defines its own app_shell/1 template function.
<LanternUI.Components.Layout.app_shell id="demo-app">
<:brand>
<.icon name="sparkles" />
<span class="lui-brand-name">Acme</span>
</:brand>
<:header>
<.badge size="sm" variant="soft">Production</.badge>
</:header>
<:actions>
<.avatar size="sm" initials="AL" />
</:actions>
<:breadcrumb>
<.breadcrumb home="/" items={@shell_crumbs} />
</:breadcrumb>
<:sidebar>
<.nav_group label="Workspace">
<.nav_item label="Dashboard" icon="chart-bar" navigate="/" active />
<.nav_item label="Tickets" icon="document" navigate="/tickets" badge={12} />
<.nav_item label="Inbox" icon="inbox" navigate="/inbox" badge={3} />
</.nav_group>
<.nav_group label="Manage">
<.nav_item label="Projects" icon="folder" navigate="/projects" />
<.nav_item label="Settings" icon="adjustments-horizontal" navigate="/settings" />
</.nav_group>
</:sidebar>
<:sidebar_footer>
<.nav_link label="Documentation" icon="globe-alt" href="/docs" />
</:sidebar_footer>
<.page_header title="Tickets" description="Every request, one flat list.">
<:actions>
<.button size="sm" variant="solid">New ticket</.button>
</:actions>
</.page_header>
<.card title="Getting started" description="Three steps to a finished page.">
<p style="margin: 0;">
Pick a block from the recipe index, swap the fixture assigns for the host LiveView assigns, and ship.
</p>
<:footer>Blocks render in the default theme and the shadcn preset.</:footer>
</.card>
</LanternUI.Components.Layout.app_shell>Block 2: dashboard with stats, chart, and activity
When to use: The page answers "where do we stand" — a stat-cards row, one chart card, and a recent-activity card. No tabs, no custom metric grids.
<.stack gap="lg" style="max-width: 1120px; margin: 0 auto;">
<.page_header title="Dashboard" description="Where the week stands at a glance.">
<:actions>
<.button size="sm" variant="outline">Export</.button>
<.button size="sm" variant="solid">New ticket</.button>
</:actions>
</.page_header>
<.stat_grid>
<:stat :for={stat <- @stats} label={stat.label} value={stat.value} subtitle={stat.subtitle} />
</.stat_grid>
<.card title="Merged per day" description="Last 14 days across 4 repos.">
<.area_chart
id="dashboard-merged"
series={@series}
height={220}
aria_label="Merged tickets per day"
/>
</.card>
<.card flush title="Recent activity" description="Latest updates, most recent first.">
<.list_row
:for={item <- @activity}
identifier={item.identifier}
title={item.title}
navigate={item.href}
>
<:leading><.status_glyph status={item.status} /></:leading>
<:trailing>{item.date}</:trailing>
</.list_row>
</.card>
</.stack>Block 3: flat index table with filter chips
When to use: The page is a record list — one flat data_table with filter chips carrying counts, a status column on each row, search, pagination, row click, and an empty state. Title and the primary action live in the breadcrumb bar; no tabs, no group bands. row_navigate (or row_patch) makes each row one real link — Enter, middle-click and open-in-new-tab work, and checkboxes, buttons and menus inside the row keep their own clicks; use row_click with a JS command when the row is not a link. Do not also put navigate on the list_row. fill pins pagination only when the parent bounds the height (see "Fill list pages" above) — without a bound the table takes its natural height.
<.stack gap="lg" style="max-width: 1120px; margin: 0 auto;">
<.breadcrumb_bar id="tickets-crumb">
<.breadcrumb home="/" items={@shell_crumbs} />
<:actions label="New ticket" navigate="/tickets/new">
<.button size="sm" variant="solid" navigate="/tickets/new">New ticket</.button>
</:actions>
</.breadcrumb_bar>
<.data_table
id="tickets"
rows={@tickets}
meta={@meta}
path="/tickets"
fill
views={["list"]}
show_checkboxes={false}
search_field={:title}
row_navigate={& &1.href}
data-lantern-list-nav
>
<:tab label="All" count={24} />
<:tab label="In progress" count={9} filters={[%{field: "status", value: "in_progress"}]} />
<:tab label="To do" count={11} filters={[%{field: "status", value: "todo"}]} />
<:tab label="Done" count={4} filters={[%{field: "status", value: "done"}]} />
<:filter
field={:status}
label="Status"
options={[{"In progress (9)", "in_progress"}, {"To do (11)", "todo"}, {"Done (4)", "done"}]}
prompt="All statuses"
/>
<:list_item :let={ticket}>
<.list_row
identifier={ticket.identifier}
title={ticket.title}
parent={ticket.parent}
selected={ticket.selected}
>
<:leading>
<.status_glyph status={ticket.status} />
<span class="lui-list-row-status">
{ticket.status |> Atom.to_string() |> String.replace("_", " ")}
</span>
</:leading>
<:meta>
<.badge size="sm">{ticket.tag}</.badge>
</:meta>
<:trailing>{ticket.date}</:trailing>
</.list_row>
</:list_item>
<:empty>
<.empty_state icon="document" title="No tickets match">
Try a different search, or create the first ticket.
<:action>
<.button size="sm" variant="solid" navigate="/tickets/new">New ticket</.button>
</:action>
</.empty_state>
</:empty>
</.data_table>
</.stack>Block 4: detail page with inspector panel
When to use: The page is one record — breadcrumb actions hold the panel toggle plus the edit action, a page_header names the record, a body card carries the content, and a collapsible side panel shows inspector sections.
<.stack gap="lg" style="max-width: 1120px; margin: 0 auto;">
<.breadcrumb_bar id="ticket-crumb">
<.breadcrumb home="/" items={@detail_crumbs} />
<:actions label="Toggle panel">
<.side_panel_toggle
id="ticket-panel-toggle"
panel_id="ticket-panel"
panel_key="ticket"
open={@panel_open}
kbd="]"
/>
</:actions>
<:actions label="Edit ticket" navigate="/tickets/241/edit">
<.button size="sm" variant="outline" navigate="/tickets/241/edit">Edit</.button>
</:actions>
</.breadcrumb_bar>
<.page_header title={@ticket.title} description={@ticket.identifier} />
<div style="display: flex; gap: 1.25rem; align-items: flex-start;">
<.card title="Description" style="flex: 1; min-width: 0;">
{@ticket.body}
<:footer>
<.progress
shape="ring"
completed={@ticket.completed}
scope={@ticket.scope}
size="sm"
label="Completion"
>
{@ticket.completed} / {@ticket.scope}
</.progress>
</:footer>
</.card>
<.side_panel id="ticket-panel" open={@panel_open} aria-label="Ticket properties">
<.inspector aria-label="Ticket">
<.inspector_section title="Properties">
<.description_list layout="dense">
<:item label="Status">
<.status_glyph status={@ticket.status} /> in progress
</:item>
<:item label="Priority">
<.priority_glyph priority={@ticket.priority} /> high
</:item>
<:item label="Tags">
<.badge size="sm">{@ticket.tag}</.badge>
</:item>
</.description_list>
</.inspector_section>
<.inspector_section title="People">
<.description_list layout="dense">
<:item label="Owner">Ada Lovelace</:item>
<:item label="Reviewer">Grace Hopper</:item>
</.description_list>
</.inspector_section>
</.inspector>
</.side_panel>
</div>
</.stack>Block 5: settings with one form per section
When to use: The page edits preferences — stacked section cards, each with its own controls and its own save row in the footer, so saving one section never moves the others.
<.stack gap="lg" style="max-width: 760px; margin: 0 auto;">
<.page_header
title="Settings"
description="Each section saves on its own — nothing else moves."
/>
<.card title="Profile" description="How your name appears on tickets and reviews.">
<.stack gap="md">
<.input id="settings-name" name="name" label="Display name" value="Ada Lovelace" />
<.input
id="settings-email"
name="email"
type="email"
label="Email"
value="ada@acme.test"
help_text="Receipts and review requests land here."
/>
</.stack>
<:footer>
<span>Saved 2m ago</span>
<.button size="sm" variant="solid">Save profile</.button>
</:footer>
</.card>
<.card title="Notifications" description="Pick which pings are worth interrupting you.">
<.stack gap="sm">
<.switch id="settings-mentions" name="mentions" label="Mentions" checked value="true" />
<.switch
id="settings-review"
name="review_requests"
label="Review requests"
checked
value="true"
/>
<.switch id="settings-weekly" name="weekly_digest" label="Weekly digest" value="false" />
</.stack>
<:footer>
<span>Saved 1h ago</span>
<.button size="sm" variant="solid">Save notifications</.button>
</:footer>
</.card>
<.card title="Appearance" description="Density and theme for this workspace.">
<.stack gap="md">
<.select
native
id="settings-theme"
name="theme"
label="Theme"
options={[{"System", "system"}, {"Light", "light"}, {"Dark", "dark"}]}
value="system"
prompt="Pick a theme"
/>
<.textarea
id="settings-signature"
name="signature"
label="Review signature"
value="Ship it."
help_text="Appended to approvals you write."
/>
</.stack>
<:footer>
<span>Saved yesterday</span>
<.button size="sm" variant="solid">Save appearance</.button>
</:footer>
</.card>
</.stack>Block 6: create/edit form with validation errors
When to use: The page creates or edits one record — a single card, an error summary on top, inline errors on the failing controls, and cancel/save in the footer.
<.stack gap="lg" style="max-width: 760px; margin: 0 auto;">
<.breadcrumb_bar id="ticket-new-crumb">
<.breadcrumb home="/" items={@form_crumbs} />
</.breadcrumb_bar>
<.card title="New ticket" description="Small, sharp titles get picked up fastest.">
<.stack gap="md">
<.alert color="danger" title="2 problems need attention">
Title can't be blank. Pick a status so the ticket lands in the right list.
</.alert>
<.input
id="ticket-title"
name="title"
label="Title"
placeholder="Visible progress ring"
value=""
errors={["can't be blank"]}
/>
<.textarea
id="ticket-body"
name="body"
label="Description"
placeholder="What changes, and how will the reviewer see it?"
value=""
help_text="Markdown welcome. Screenshots beat paragraphs."
/>
<.select
native
id="ticket-status"
name="status"
label="Status"
options={[{"To do", "todo"}, {"In progress", "in_progress"}, {"Done", "done"}]}
value=""
prompt="Pick a status"
errors={["can't be blank"]}
/>
</.stack>
<:footer>
<.button size="sm" variant="ghost" navigate="/tickets">Cancel</.button>
<.button size="sm" variant="solid">Create ticket</.button>
</:footer>
</.card>
</.stack>Block 7: login page
When to use: The page signs someone in — a centered card, one primary action, SSO second, and a muted invite line. No app shell, no sidebar.
<div style="max-width: 400px; margin: 3rem auto 0;">
<.card>
<.stack gap="md">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<.icon name="sparkles" />
<span class="lui-brand-name">Acme</span>
</div>
<.page_header title="Welcome back" description="Sign in to your workspace." />
<.input
id="login-email"
name="email"
type="email"
label="Email"
placeholder="ada@acme.test"
autocomplete="email"
/>
<.input
id="login-password"
name="password"
type="password"
label="Password"
placeholder="••••••••"
autocomplete="current-password"
errors={["is incorrect — try again or reset it"]}
/>
<.button variant="solid" style="width: 100%;">Sign in</.button>
<.separator text="or continue with" />
<.button variant="outline" style="width: 100%;">Continue with SSO</.button>
</.stack>
<:footer>No account yet? Ask your workspace admin for an invite.</:footer>
</.card>
</div>Block 8: confirm-destructive dialog plus empty/loading/error states
When to use: The page ends something — the danger card carries the trigger, an alert_dialog states the consequence, and the same page shows the empty, loading, and error states so none of them ships unstyled.
<.stack gap="lg" style="max-width: 1120px; margin: 0 auto;">
<.page_header
title="Danger zone"
description="Irreversible actions wait behind a confirmation."
/>
<.card
title="Delete workspace"
description="Removes every ticket, inbox item, and invite. This cannot be undone."
>
<.button variant="solid" color="danger">Delete workspace…</.button>
</.card>
<.alert_dialog id="delete-workspace" open>
<:title>Delete this workspace?</:title>
<:description>
Every ticket, inbox item, and invite goes with it. Type the workspace name to confirm.
</:description>
<:cancel><.button variant="outline">Cancel</.button></:cancel>
<:action><.button variant="solid" color="danger">Delete workspace</.button></:action>
</.alert_dialog>
<.card_grid min="16rem">
<.card title="Empty">
<.empty_state icon="document" title="No tickets yet">
Create the first one to get the list going.
<:action><.button size="sm" variant="solid">New ticket</.button></:action>
</.empty_state>
</.card>
<.card title="Loading">
<.stack gap="sm">
<.loading label="Loading tickets…" />
<.skeleton style="height: 0.875rem;" />
<.skeleton style="height: 0.875rem; width: 62%;" />
</.stack>
</.card>
<.card title="Error">
<.stack gap="sm">
<.alert color="danger" title="Tickets didn't load">
The request timed out. Check your connection and try again.
</.alert>
<div><.button size="sm" variant="outline">Retry</.button></div>
</.stack>
</.card>
</.card_grid>
</.stack>