Core wrapper components shipped by JobyKit. Each component:
- Carries
data-component="JobyKit.CoreComponents.<name>"on its root element. - Declares every prop with
attr(variant/size enums viavalues:). - Accepts
attr :rest, :globalso callers can pass id/class/aria-/ phx- through. - Composes daisyUI primitives + theme tokens internally; the
classattr (where exposed) is additive, layered on top of the wrapper's identity classes.
Hosts register these against JobyKit.CoreComponents in their
manifest:
component JobyKit.CoreComponents, :button,
category: :core,
daisy_basis: "btn",
summary: "Standard text button.",
preview: &MyAppWeb.DesignPreviews.button_preview/1And expose them by importing this module into their core_components
/ html_helpers (or _web.ex) so call sites can use the <.button>
form.
Components
flash/1,flash_group/1— toast-style flashesbadge/1— status chip with a semantic tonebutton/1— text/link button with variant, size, and shapecard/1— content surface with eyebrow/title/actions slotseyebrow/1— small uppercase labelheader/1— page or section headericon/1— Heroicon spaninput/1— form input (text, email, select, textarea, checkbox…)list/1— generic listmodal/1— server-driven dialogtable/1— table with col/action slots, stream-awaretheme_toggle/1— system / light / dark control
Plus the JS helpers show/2, hide/2, and the i18n-free
translate_error/1.
Summary
Functions
Status chip with a semantic tone.
Standard text button. Renders as <button> by default, or <.link>
when href/navigate/patch is passed via :rest.
daisyUI card with optional eyebrow, title, and actions slots.
Small uppercase label that sits above a heading or leads a data pair.
Renders a single flash notice as a daisyUI alert.
Renders the standard flash group: :info and :error flashes plus
the disconnected/server-error toasts wired to phx-disconnected /
phx-connected. Hosts call this from their root layout.
Page or section header, with optional eyebrow, subtitle, and actions.
JS command for fading an element out.
Renders a Heroicon.
Renders a form input with label and error messages.
Generic data list, one row per :item slot.
Dialog whose visibility is driven by the server.
JS command for fading an element in.
Generic table with :col slots and an optional :action slot.
Segmented system / light / dark theme control.
Translate an Ecto error tuple {msg, opts} into a plain string by
interpolating %{key} placeholders. No Gettext dependency — hosts
that need i18n should override this function (or wrap <.input>).
Functions
Status chip with a semantic tone.
<.badge tone="ok">Healthy</.badge>
<.badge tone="danger" variant="solid">Failed</.badge>tone names the state, not a colour, so the palette stays consistent
across apps and themes. It deliberately shares neutral and danger
with button/1 — the same word means the same thing wherever it
appears.
This exists because it was missing: one app maintained five separate tone-to-class functions mapping the same ok/warn/critical/neutral set to border+bg+text triples, and one of them had been copied verbatim into a second file — the exact drift the kit's own guidance warns about.
Attributes
tone(:string) - The state being reported, not a colour. Defaults to"neutral". Must be one of"neutral","ok","warn","danger", or"info".variant(:string) - Defaults to"soft". Must be one of"soft","solid", or"outline".size(:string) - Defaults to"sm". Must be one of"xs","sm","md", or"lg".class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required)
Standard text button. Renders as <button> by default, or <.link>
when href/navigate/patch is passed via :rest.
<.button>Send</.button>
<.button variant="primary" size="sm">Save</.button>
<.button navigate={~p"/dashboard"}>Home</.button>type passes through, so a non-submitting button inside a form is
<.button type="button">. Omitted, the browser default applies —
submit inside a form.
Tone
variant carries meaning, not just colour — danger is how a
destructive action reads as destructive:
<.button variant="danger">Delete workspace</.button>
<.button variant="ghost">Dismiss</.button>The default (nil) is the soft-primary treatment, also nameable as
soft when the value is computed at runtime.
Icon-only buttons
shape sizes the button to a single glyph. Always give it an
accessible name, since there is no text to read:
<.button shape="circle" variant="ghost" aria-label="Close">
<.icon name="hero-x-mark" class="size-4" />
</.button>Attributes
class(:any) - Defaults tonil.variant(:string) - Tone. Defaults to the soft-primary treatment;dangerfor destructive actions. Must be one of"soft","primary","neutral","ghost", or"danger".size(:string) - Control height. Defaults to"md". Must be one of"xs","sm","md", or"lg".shape(:string) - Square off an icon-only button. Give it an aria-label. Must be one of"circle", or"square".- Global attributes are accepted. Supports all globals plus:
["href", "navigate", "patch", "method", "download", "name", "value", "disabled", "type", "form"].
Slots
inner_block(required)
daisyUI card with optional eyebrow, title, and actions slots.
<.card>
<:eyebrow>/design</:eyebrow>
<:title>Kit-curated wrappers</:title>
Body content goes here.
<:actions><.button>Open</.button></:actions>
</.card>Body content
The body renders as direct children of card-body, so the card's own
gap spaces whatever you put there — no inner wrapper to fight.
The card takes no opinion on body typography. prose={true} opts into
the muted small-text treatment for cards that really are prose;
body_class sets it yourself.
<.card prose>Explanatory copy.</.card>
<.card body_class="text-base">Dense data, styled by the caller.</.card>Attributes
class(:any) - Defaults tonil.body_class(:any) - Utilities for the card-body element. Defaults tonil.variant(:string) - Surface treatment. Defaults to"bordered". Must be one of"bordered","ghost", or"elevated".prose(:boolean) - Apply the muted small-text treatment to body content. Defaults tofalse.- Global attributes are accepted.
Slots
eyebrowtitleactionsinner_block(required)
Small uppercase label that sits above a heading or leads a data pair.
<.eyebrow>Workspace</.eyebrow>This is the most-duplicated string in the fleet: one app carried 326 hand-typed instances of the same mono-uppercase treatment, with letter spacing drifting across nine values and six font sizes because every one was written by hand. One definition, one look.
card/1 and header/1 render their :eyebrow slots through this, so
the three stay identical by construction.
Attributes
class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required)
Renders a single flash notice as a daisyUI alert.
Positioning belongs to the container, not the notice: flash_group/1
supplies the single toast that stacks every notice. Rendered on its
own, flash/1 sits inline wherever you put it.
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} title="Heads up">Something happened</.flash>Attributes
id(:string) - Defaults tonil.flash(:map) - Defaults to%{}.title(:string) - Defaults tonil.kind(:atom) (required) - Which flash key to read and how to style it. Must be one of:info,:success,:warning, or:error.class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block
Renders the standard flash group: :info and :error flashes plus
the disconnected/server-error toasts wired to phx-disconnected /
phx-connected. Hosts call this from their root layout.
This is the single toast container for the page — every notice
stacks inside it. The container is click-through
(pointer-events-none) so the empty corner never intercepts clicks;
each notice re-enables pointer events for its own dismiss handler.
Attributes
id(:string) - Defaults to"flash-group".flash(:map) (required)class(:any) - Defaults tonil.- Global attributes are accepted.
Page or section header, with optional eyebrow, subtitle, and actions.
<.header>
Team settings
<:eyebrow>Workspace</:eyebrow>
<:subtitle>Manage members and their permissions.</:subtitle>
<:actions><.button variant="primary">Invite</.button></:actions>
</.header>level picks the heading element so a section header doesn't emit a
second <h1>; size picks the type scale independently, and
title_class overrides it outright when the app has its own display
face. The header carries no outer spacing — the parent owns that.
Attributes
class(:any) - Defaults tonil.title_class(:any) - Replaces the heading's type scale. Defaults tonil.level(:string) - Defaults to"h1". Must be one of"h1","h2", or"h3".size(:string) - Defaults to"section". Must be one of"page", or"section".- Global attributes are accepted.
Slots
inner_block(required)eyebrowsubtitleactions
JS command for fading an element out.
Renders a Heroicon.
Heroicons come in three styles — outline, solid, and mini. Default is
outline; pass name="hero-foo-solid" or name="hero-foo-mini" for the
others. The host must have the Heroicons CSS plugin installed (Phoenix
ships this by default in assets/vendor/heroicons.js).
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />Attributes
name(:string) (required)class(:any) - Defaults to"size-4".- Global attributes are accepted.
Renders a form input with label and error messages.
Pass field={@form[:foo]} for the common case; the input pulls id,
name, value, and errors from the form field. Otherwise pass name,
value, errors explicitly.
Supported types: text, email, password, number, search, tel, url, date, datetime-local, month, time, week, color, file, hidden, checkbox, select, textarea.
<.input field={@form[:email]} type="email" label="Email" />
<.input field={@form[:role]} type="select" options={["Admin": "admin"]} />Class composition
class and any global attributes land on the root <fieldset>,
the same as every other kit wrapper — use it for layout (grid
placement, width, margins). input_class adds utilities to the
control itself, on top of the daisyUI class set the wrapper composes
(input / select / textarea / checkbox).
<.input field={@form[:email]} class="col-span-2" input_class="font-mono" />The wrapper carries no outer margin. Spacing belongs to the
parent — reach for space-y-* or gap-* on the container.
Width works through the root: the control is always w-full, so
constrain the root and the control follows.
<.input field={@form[:code]} class="w-32" />Attributes
id(:any) - Defaults tonil.name(:any)label(:string) - Defaults tonil.value(:any)type(:string) - Defaults to"text". Must be one of"checkbox","color","date","datetime-local","email","file","month","number","password","search","select","tel","text","textarea","time","url","week", or"hidden".field(Phoenix.HTML.FormField)errors(:list) - Defaults to[].checked(:boolean)prompt(:string) - Defaults tonil.options(:list)multiple(:boolean) - Defaults tofalse.class(:any) - Utilities for the root fieldset — layout, width, spacing. Defaults tonil.input_class(:any) - Utilities for the control itself. Defaults tonil.- Global attributes are accepted. Supports all globals plus:
["accept", "autocomplete", "capture", "cols", "disabled", "form", "list", "max", "maxlength", "min", "minlength", "multiple", "pattern", "placeholder", "readonly", "required", "rows", "size", "step"].
Generic data list, one row per :item slot.
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>Attributes
class(:any) - Defaults tonil.title_class(:any) - Replaces the title's type treatment. The default isfont-bold. Defaults tonil.- Global attributes are accepted.
Slots
item(required) - Accepts attributes:title(:string) (required)
Dialog whose visibility is driven by the server.
<.modal id="confirm" show={@confirming?} on_cancel={JS.push("cancel")}>
<:title>Delete workspace</:title>
This removes every peer and session in it. It cannot be undone.
<:actions>
<.button variant="ghost" phx-click={JS.push("cancel")}>Keep it</.button>
<.button variant="danger" phx-click={JS.push("delete")}>Delete</.button>
</:actions>
</.modal>show is a plain assign rather than client-side state, so the dialog
can't disagree with the LiveView that owns it, and reconnecting
restores the right thing.
Dismissal
on_cancel runs for all three ways out — the close button, the
backdrop, and Escape — so there is one path to handle instead of the
separate close/dismiss handlers apps ended up writing. The keydown
listener is only attached while the dialog is open, so closed dialogs
on the page cost nothing and can't swallow the key.
Set dismissable={false} for a dialog that must be resolved through
its actions; that drops the close button, the backdrop handler, and
the Escape binding together.
Previews
.modal is position: fixed, so rendering one inside a preview card
would cover the page. static renders just the box, in flow, for
design pages and documentation.
Attributes
id(:string) (required)show(:boolean) - Defaults tofalse.on_cancel(:any) - JS command or event run on any dismissal. Defaults tonil.dismissable(:boolean) - Defaults totrue.static(:boolean) - Render in flow instead of as an overlay. Defaults tofalse.class(:any) - Defaults tonil.box_class(:any) - Utilities for the modal box. Defaults tonil.- Global attributes are accepted.
Slots
titleinner_block(required)actions
JS command for fading an element in.
Generic table with :col slots and an optional :action slot.
:rows takes a plain list or a LiveView stream (see
Phoenix.LiveView.stream/4).
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
<:empty>No users yet.</:empty>
</.table>Streams enumerate as {dom_id, item}
A stream yields tuples, so destructure in the slot — or hand the
unwrapping to row_item:
<:col :let={{_id, user}} label="id">{user.id}</:col>
<.table id="users" rows={@stream} row_item={fn {_id, u} -> u end}>Styling hooks
zebra={false} drops the striping, size sets density, and the
action cell carries data-table-actions so a host override can target
it precisely instead of guessing with :last-child.
Attributes
id(:string) (required) - Identifies the row container. Streams require it; it lands on<tbody>.table_id(:string) - Optional id for the<table>element itself, sinceidis taken. Defaults tonil.rows(:any) (required) - A list, or a LiveView stream fromPhoenix.LiveView.stream/4(which enumerates as{dom_id, item}).class(:any) - Defaults tonil.zebra(:boolean) - Row striping. Turn off for dense data. Defaults totrue.size(:string) - Defaults to"md". Must be one of"xs","sm","md", or"lg".row_id(:any) - Defaults tonil.row_click(:any) - Defaults tonil.row_item(:any) - Defaults to&Function.identity/1.- Global attributes are accepted.
Slots
col(required) - Accepts attributes:label(:string)
actionempty- Rendered in place of the body when there are no rows.
Segmented system / light / dark theme control.
<.theme_toggle />Pairs with the theme script Phoenix puts in root.html.heex, which
applies the stored choice before first paint and listens for the
phx:set-theme event these buttons dispatch. Selection state is read
straight off <html data-theme> / data-theme-source in CSS, so the
control stays correct without round-tripping through the server.
Generated apps get this in their layout. If you are installing into an
existing app, make sure root.html.heex carries that script — without
it the buttons dispatch into nothing.
Attributes
class(:any) - Defaults tonil.- Global attributes are accepted.
Translate an Ecto error tuple {msg, opts} into a plain string by
interpolating %{key} placeholders. No Gettext dependency — hosts
that need i18n should override this function (or wrap <.input>).
Opts whose placeholder isn't present in the message are never
stringified, so non-String.Chars values (type: {:array, :string},
validation: :cast, and friends — which Ecto attaches to every cast
error) pass through harmlessly.