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 / sheeta transformed <div><.drawer id=… side=…>
a popover / dropdown panelposition:absolute + z-index<.popover id=…>
an action menua custom list of links<.menu id=…> + <.menu_item>
a tooltiptitle= or a CSS ::after<.tooltip id=… text=…>
a date / time field<input type="date"> / type="time"<.date_input> / <.datetime_input>
a text/email/number fieldbare <input><.input field=… type=…>
a checkbox / radio / switchbare <input type=checkbox><.toggle type=…>
a slider / range<input type="range"><.slider … range?>
a one-segment toggle groupradio buttons styled by hand<.segmented options=…>
tabsa custom show/hide<.tabs id=…> + <:tab>
a collapsiblea custom toggle<.accordion> / <:item>
a data tablea bare <table><.table> + <.pagination>
a toast / flasha custom bannerroute 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 for CoreComponents.input/1): type of text|email|password|number|tel|url|search|checkbox|select|textarea|hidden routes to the right Skua control, so generated phx.gen.auth / phx.gen.live forms look Skua-styled with no edits.
  • Pass field (a Phoenix.HTML.FormField) — Skua derives id/name/value and shows changeset errors only after the field is used, linked via aria-describedby.
  • Bare name/value is the escape hatch for inputs outside a form.
  • Checkboxes already emit a hidden false companion — 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, so phx-change works); 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 with Skua.Phone.validate_phone/3 in your changeset. Bring-your-own validation — mix skua.install --with-phone wires ex_phone_number for 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 id so morphdom never replaces the node (which would drop top-layer state).
  • dialog/drawer are native <dialog> + showModal() (inert backdrop, focus trap, Esc-to-close). Open via Overlay.open_dialog(id), close with a [data-sk-close] button or Overlay.close_dialog(id).
  • popover/menu/select flip and shift to stay on-screen and open beside their parent when nested — you never set positions or z-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.