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:

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>