Skua is a headless-first, token-styled component kit for Phoenix LiveView. Every
interactive surface (dropdowns, dialogs, menus, date pickers, tooltips) renders
in the browser top layer and is viewport-aware (auto flip/shift/clamp), so
nothing is ever clipped by an ancestor's overflow. Behavior ships as one hooks
bundle; styling is 100% CSS tokens.
The one rule that matters
Never hand-roll a control the browser or a CSS hack would render. Use the Skua
component. Skua exists so you don't drop a native <select>, a CSS modal, or an
absolutely-positioned dropdown into an app — those look unstyled (the OS draws the
open list), break in dark mode, and clip inside scroll containers. Every Skua
overlay is already token-styled and viewport-aware.
| If you're tempted to write… | ❌ Don't | ✅ Use instead |
|---|---|---|
| a dropdown / combobox | <select>, <input list>, a custom position:absolute menu | <.select …> |
| a multi-select / tag input | <select multiple> | <.select multiple display="badge" …> |
| a modal | <div class="modal">, a custom backdrop | <.dialog id=…> |
| a side panel / sheet | a transformed <div> | <.drawer id=… side=…> |
| a popover / dropdown panel | position:absolute + z-index | <.popover id=…> |
| an action menu | a custom list of links | <.menu id=…> + <.menu_item> |
| a tooltip | title= or a CSS ::after | <.tooltip id=… text=…> |
| a date / time field | <input type="date"> / type="time" | <.date_input> / <.datetime_input> |
| a text/email/number field | bare <input> | <.input field=… type=…> |
| a checkbox / radio / switch | bare <input type=checkbox> | <.toggle type=…> |
| a slider / range | <input type="range"> | <.slider … range?> |
| a one-segment toggle group | radio buttons styled by hand | <.segmented options=…> |
| tabs | a custom show/hide | <.tabs id=…> + <:tab> |
| a collapsible | a custom toggle | <.accordion> / <:item> |
| a data table | a bare <table> | <.table> + <.pagination> |
| a toast / flash | a custom banner | route flashes through <Toast.toaster> |
If a control you need isn't listed here, build it with bare elements and add
the sk-* classes / tokens (see "Going outside the rails") — don't ship an
unstyled native control.
Setup (once — usually done by mix skua.install)
# lib/my_app_web.ex — inside html_helpers
use Skua/* assets/css/app.css — after the tailwind import */
@import "../../deps/skua/assets/css/skua.css";
@source "../../deps/skua/lib";// assets/js/app.js
import { hooks as skuaHooks } from "skua"
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: { ...skuaHooks, ...appHooks },
})Forms — always drive with field={@form[:x]}
<.input field={@form[:email]} type="email" label="Work email" required />
<.input field={@form[:age]} type="number" label="Age" />
<.textarea field={@form[:bio]} label="Bio" rows={4} />
<.toggle type="switch" field={@form[:notify]} label="Email me" />
<.toggle type="checkbox" field={@form[:tos]} label="I agree" />
<.segmented field={@form[:view]} options={["List", "Board", "Calendar"]} label="View" />
<.slider field={@form[:volume]} min={0} max={100} label="Volume" />
<.slider name="price" range value={[20, 80]} min={0} max={100} step={5} label="Price" />
<.otp_input field={@form[:code]} length={6} group={3} /><.input>is polymorphic (a drop-in forCoreComponents.input/1):typeoftext|email|password|number|tel|url|search|checkbox|select|textarea|hiddenroutes to the right Skua control, so generatedphx.gen.auth/phx.gen.liveforms look Skua-styled with no edits.- Pass
field(aPhoenix.HTML.FormField) — Skua derives id/name/value and shows changeset errors only after the field is used, linked viaaria-describedby. - Bare
name/valueis the escape hatch for inputs outside a form. - Checkboxes already emit a hidden
falsecompanion — don't add your own.
Selection
<.select field={@form[:status]} label="Status"
options={[{"Open", "open"}, {"Done", "done"}]} prompt="Choose…" />
<.select field={@form[:teams]} multiple display="badge" searchable creatable
options={@teams} placeholder="Add teams…" />
<.phone field={@form[:phone]} label="Phone" country="US" />
<.date_input field={@form[:due]} label="Due date" />
<.datetime_input field={@form[:starts]} label="Starts at" format="12" />- Options are
{label, value}(or{label, value, desc}) tuples. The Skua select is a real ARIA combobox (a hidden<select>carries the value, sophx-changeworks); the visible list is a top-layer, themed panel — never the OS dropdown. Full keyboard support (arrows/Home/End/type-ahead/Enter/Escape). <.phone>writes a canonical E.164 value to a hidden input; validate withSkua.Phone.validate_phone/3in your changeset. Bring-your-own validation —mix skua.install --with-phonewiresex_phone_numberfor full checks.<.date_input>/<.datetime_input>are token-styled calendars in the top layer — never<input type="date">(whose picker the OS draws).
Overlays — all top-layer, all viewport-aware
<.popover id="invite" pad width="280px">
<:trigger><.button>Invite</.button></:trigger>
<.input name="email" placeholder="name@co.com" />
</.popover>
<.menu id="row-actions" trigger_variant="ghost">
<:trigger>Actions</:trigger>
<.menu_item phx-click="edit">Edit</.menu_item>
<.menu_separator />
<.menu_item danger phx-click="delete">Delete</.menu_item>
</.menu>
<.tooltip id="copy-tip" text="Copy to clipboard">
<.button icon_only aria-label="Copy"><.icon name="hero-clipboard" /></.button>
</.tooltip>
<.dialog id="confirm">
<:title>Delete project?</:title>
<:subtitle>This cannot be undone.</:subtitle>
<p class="sk-p">All data will be removed.</p>
<:footer>
<.button data-sk-close>Cancel</.button>
<.button variant="danger" phx-click="delete">Delete</.button>
</:footer>
</.dialog>
<.drawer id="filters" side="right">
<:title>Filters</:title>
<.input field={@form[:q]} label="Search" />
<:footer><.button variant="primary" data-sk-close>Apply</.button></:footer>
</.drawer>
<.button phx-click={Skua.Components.Overlay.open_dialog("confirm")}>Delete…</.button>- Every overlay needs a stable DOM
idso morphdom never replaces the node (which would drop top-layer state). dialog/drawerare native<dialog>+showModal()(inert backdrop, focus trap, Esc-to-close). Open viaOverlay.open_dialog(id), close with a[data-sk-close]button orOverlay.close_dialog(id).popover/menu/selectflip and shift to stay on-screen and open beside their parent when nested — you never set positions orz-index.- Put Skua inputs inside overlays too — a
<.select>or<.input>in a dialog is fine (the panel renders in the top layer, above the dialog).
Display, feedback & navigation
<.card><:title>Plan</:title>…<:footer><.button>Upgrade</.button></:footer></.card>
<.header>Team<:subtitle>Manage access</:subtitle><:actions><.button>Invite</.button></:actions></.header>
<.list><:item title="Email">ada@x.com</:item></.list>
<.badge variant="ok">Live</.badge> <.avatar name="Ada Lovelace" />
<.alert variant="warning" title="Heads up">Trial ends in 3 days.</.alert>
<.accordion id="faq"><:item title="Q?">A.</:item></.accordion>
<.breadcrumb><:crumb navigate={~p"/"}>Home</:crumb><:crumb>Here</:crumb></.breadcrumb>
<.progress value={70} /> <.skeleton variant="text" width="60%" /> <.spinner />
<.tabs id="settings"><:tab label="Profile">…</:tab><:tab label="Billing">…</:tab></.tabs>
<.table id="users" rows={@users} sort={@sort} on_sort="sort">
<:col :let={u} field={:name} label="Name" sortable>{u.name}</:col>
<:action :let={u}><.button variant="ghost">Edit</.button></:action>
<:empty>No users yet.</:empty>
</.table>
<.pagination page={@page} per_page={@per} total={@total} on_page="page"
per_page_options={[10, 25, 50]} on_per_page="per_page" />Toasts: render <Toast.toaster /> once in your layout and push with
Skua.Components.Toast.toast(socket, :success, "Saved", title: "Done"). Flash
messages route through <Toast.flash_group flash={@flash} /> (the installer wires
this). Auto-dismiss scales with severity.
Theming — one place, no per-component work
Override the public tokens in your app's :root and everything re-skins — the
whole point of the system:
/* assets/css/app.css */
:root {
--skua-accent: #4f46e5; /* selected / primary */
--skua-radius: 0.75rem; /* one knob → all rounding */
--skua-font-size: 0.9375rem;/* one knob → the whole type scale */
--skua-space: 4px; /* one knob → all padding/gaps */
}Tokens: --skua-bg/-bg-elevated/-fg/-fg-muted/-border/-ring/-accent/-accent-fg
(neutrals) and the status colors --skua-danger/-success/-warning/-info
(badges, toasts, alerts and the chip soft-fills all derive from these, so retune
one and everything stays in harmony — no per-component colours are hardcoded).
Plus --skua-radius, --skua-font, --skua-font-mono, --skua-font-size,
--skua-icon-size, --skua-space, --skua-shadow, and the 3 motion tokens. The
light theme lives under :root[data-theme="light"]; ship <.theme_toggle />.
Status badges/alerts/toasts: <.badge variant="success|warning|info|danger">,
<.alert variant=…>, and the toast kinds all read those four tokens. Chips and
badges share --sk-chip-r (defaults to --sk-r-sm) so their rounding always
matches.
Going outside the rails (50% Skua is fine)
Skua is scoped to sk-* classes + your tokens, so it never fights your own CSS:
- Re-skin globally → override
--skua-*tokens (above). - One instance → every component takes
class; add your Tailwind:<.button class="w-full">Save</.button>. - Mix freely → use Skua for the hard controls (select, date, dialog, menu, combobox) and hand-roll the rest with your own markup — they coexist.
- Opt a region out → don't use Skua components /
sk-*classes there.
The only hard rule still applies even when you go custom: don't ship a bare
native <select>/<dialog>/<input type=date> — those are the ones the OS
styles and the browser positions. Reach for the Skua component or style your own
fully.
Conventions
- Every overlay/select needs a stable
id. - Don't wrap Skua inputs in your own form-level error display — Skua handles it.
- Skua adds zero third-party JS and no runtime CSS framework.