SlopUI

A semantic, themeable, accessible component library for Phoenix LiveView, built on modern vanilla CSS. No Tailwind, no utility classes, no build step beyond the esbuild you already have.

Installation

def deps do
[{:slop_ui, "~> 0.1"}]
end

Requires Phoenix LiveView 1.1 or later.

In lib/my_app_web.ex, inside html_helpers, replace the generated CoreComponents import and add SlopUI:

import MyAppWeb.CoreComponents, except: [button: 1, input: 1, table: 1]
use SlopUI

These exclusions match Phoenix 1.8. If your CoreComponents defines other overlapping names (such as label/1 or error/1), exclude those too.

In assets/css/app.css (the default Phoenix esbuild config resolves slop_ui via NODE_PATH=deps):

@import "slop_ui/css";

Make sure your CSS is processed by your asset bundler. With plain esbuild, include both js/app.js and css/app.css in its arguments and use --outdir=../priv/static/assets so the output matches Phoenix’s /assets/js/app.js and /assets/css/app.css layout links.

In assets/js/app.js, merge these hooks with your existing hooks (including Phoenix’s colocated hooks, if present):

import { hooks as slopHooks } from "slop_ui"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...slopHooks },
params: { _csrf_token: csrfToken },
})

In your root layout <head>, before the stylesheet, so the persisted theme applies before first paint:

<.theme_script />

Usage

<.button color="accent" phx-click={SlopJS.open_dialog("#confirm")}>Delete</.button>
<.dialog id="confirm" on_close={JS.push("cancelled")}>
<:title>Delete post?</:title>
<:description>This cannot be undone.</:description>
<:footer>
<form method="dialog"><.button type="submit" variant="outline">Cancel</.button></form>
<.button color="danger" phx-click="delete">Delete</.button>
</:footer>
</.dialog>
<.input field={@form[:email]} type="email" label="Email" required />
<.menu id="user-menu" placement="bottom-end">
<:trigger variant="outline">Account</:trigger>
<.menu_item navigate={~p"/settings"}>Settings</.menu_item>
<.menu_separator />
<.menu_item color="danger" phx-click="logout">Sign out</.menu_item>
</.menu>
<.theme_toggle />

Components

GroupComponents
Actionsbutton, button_group, toggle, toggle_group, copy_button, menu (+ menu_item, menu_sub, menu_label, menu_separator), context_menu, command (⌘K palette)
Formsinput (text, textarea, select, checkbox, radio, switch… with prefix/suffix adornments), password_input, label, fieldset, error, radio_group, checkbox_group, select (custom, single/multiple), combobox (client or server filtered), slider, number_input, pin_input, tag_input, rating, color_input, date_picker, date_range_picker, calendar, time_picker, upload (LiveView uploads dropzone); counter on any text input
Overlaysdialog, alert_dialog, sheet, popover, hover_card, tooltip, toaster / toast
Contentcard, table (sortable, selectable rows), tree, carousel, accordion, collapsible, tabs, alert, badge, avatar, avatar_group, skeleton, progress, spinner, kbd, separator, description_list, stat, timeline, stepper, empty_state
Navigationbreadcrumbs, pagination, page_header
Content (cont.)markdown (GFM via the optional mdex dependency, rendered into .sl-prose), markdown_editor (toolbar, shortcuts, preview, image uploads)
Shellapp_shell, sidebar, nav, nav_group, nav_item, nav_collapsible, topbar_end, indicator, auth_layout
Layoutstack, cluster, grid, container, split_panel, scroll_area
Themetheme_toggle, theme_script

Every interactive component follows the matching WAI-ARIA Authoring Practices pattern and is driven by native platform features first: <dialog> for modals and sheets, the popover API for menus, tooltips and listboxes, <details name> for accordions, and a hidden native <select> behind the custom select so forms and phx-change work unchanged.

Form behavior

required prevents empty submission for native inputs, custom selects, comboboxes, and choice groups. A required checkbox group needs at least one enabled selection. Combobox validation checks the committed value, not the search query; with allow_custom, free text can become a value. Invalid custom controls show their validation message and receive focus on the visible control.

disabled applies to auxiliary controls and excludes disabled combobox values from form submission. readonly date/time pickers also disable popup selection. Loading buttons use native disabled; a loading navigation button temporarily renders as a disabled button, then becomes a link again when loading ends.

Keyboard support

Every component with its own interaction model implements the matching WAI-ARIA keyboard pattern, and mix sink.smoke exercises each one in a real browser. In short: arrow keys move within menus, tabs, accordions, trees, listboxes, radio-style groups and calendar grids; Home/End jump to the ends; Enter/Space activate; Escape closes any overlay and returns focus to what opened it. Details worth knowing:

Newer components in brief

App shell and templates

app_shell gives you the page frame: a sidebar that becomes a modal sheet below 64rem, a sticky topbar, and the main area. sidebar, nav, nav_group, nav_item and nav_collapsible build the navigation with aria-current on the active page; indicator puts a count or dot on a button; auth_layout centres a sign-in or sign-up panel.

The kitchen sink ships six full-page templates built only from library components, under /templates/*: dashboard, list, detail, settings, sign-in and sign-up. They're the fastest way to see how the pieces compose, and the source in dev/slop_ui/sink/templates/ is meant to be copied.

Markdown

<.markdown text={@body} /> renders GitHub-flavoured Markdown through MDEx, an optional dependency:

{:mdex, "~> 0.13"}

Output lands in a .sl-prose container, which styles headings, lists, task lists, tables, blockquotes, code and footnotes from the tokens, so it follows palette, density and scheme. The result is sanitized by default and raw HTML is dropped unless you pass unsafe. For highlighted code blocks add Lumis and select the Lumis-enabled MDEx NIF:

{:lumis, "~> 0.8"}
config :mdex_native, syntax_highlighter: :lumis

.sl-prose also works on its own for any HTML you render yourself.

Markdown editor

<.markdown_editor field={@form[:body]} toolbar="full" upload={@uploads.images} /> is a GitHub-style editor: a Markdown textarea with a formatting toolbar ("simple", "full", or your own list of tools plus a :tool slot), keyboard shortcuts, list continuation and indenting, a Write/Preview switch rendered by markdown, and a fullscreen mode.

Images go through LiveView uploads. Pass an upload declared with auto_upload: true and a progress callback; pasting, dropping, or attaching an image inserts an "Uploading" placeholder, and once your callback has stored the file it pushes the URL back with SlopUI.Components.MarkdownEditor.push_image/3, which turns the placeholder into ![name](url). Storage is yours (local, S3, anywhere that yields a URL); the module docs carry the full recipe and the kitchen sink's Editor page runs it end to end.

Client-side search syntax

The combobox (client mode) and the command palette share one matcher:

The matcher lives in assets/js/match.js and has its own tests: mix assets.test.

Translations

Every built-in string (button labels like "Close" and "Dismiss", calendar navigation, upload errors, the command palette footer, and so on) goes through Gettext under the slop_ui domain. English is the default. To translate them in your app, point the library at your backend and seed your priv/gettext with the shipped template:

# config/config.exs
config :slop_ui, gettext_backend: MyAppWeb.Gettext
cp deps/slop_ui/priv/gettext/slop_ui.pot priv/gettext/
mix gettext.merge priv/gettext --locale de

Strings then follow Gettext.get_locale/0 like the rest of your app. Without Gettext, set config :slop_ui, translator: {Mod, :fun}; the function gets (msgid, bindings) and returns a string. Any default label can also be overridden per call through the component's own attribute.

Icons

The glyphs the library's own chrome uses (<.icon name="check" />, about fifty of them) are Phosphor regular-weight icons vendored into SlopUI.Icons, so the library carries no icon dependency (MIT; see priv/phosphor/LICENSE).

For your app's icons, define a module with use SlopUI.Icons. SVGs are read at compile time and embedded, a name that does not exist fails the build with the closest matches, and editing a file recompiles the module:

defmodule MyAppWeb.Icons do
use SlopUI.Icons,
sets: [
phosphor: [weight: :regular, icons: ~w(house users bell)],
duo: [preset: :phosphor, weight: :duotone, icons: ~w(heart)],
hero: [style: :outline, icons: ~w(arrow-path)],
app: [dir: "assets/icons", icons: :all]
]
end
<MyAppWeb.Icons.icon name="phosphor-house" />
<MyAppWeb.Icons.icon name="hero-arrow-path" class="sl-icon-lg" />
<MyAppWeb.Icons.icon name="app-logo" aria-label="Acme" />

Each set is keyed by its prefix (prefix: false drops it). The Phosphor and Heroicons presets read from git deps that are checked out but never compiled:

{:phosphor, github: "phosphor-icons/core", sparse: "assets", depth: 1, app: false, compile: false},
{:heroicons, github: "tailwindlabs/heroicons", sparse: "optimized", depth: 1, app: false, compile: false}

Phosphor weights: :regular, :thin, :light, :bold, :fill, :duotone. Heroicons styles: :outline, :solid, :mini, :micro. Any folder of SVGs works as a custom set; width/height/id/class are stripped and black fills become currentColor. Icons render aria-hidden unless you pass aria-label, and size with the font (.sl-icon, .sl-icon-sm/lg/xl).

Theming

Three tiers: global tokens, component tokens, component rules. Override at whichever level you need, in your own (unlayered) CSS:

:root {
--sl-hue-accent: 160; /* rebrand everything */
--sl-radius-md: 0.25rem; /* squarer controls */
}
.sl-button { --sl-button-radius: 9999px; } /* one component */

Theme modes:

Presets and appearance axes

Import slop_ui/assets/css/themes.css after the main stylesheet to get six audited colour presets, then set attributes on <html> or any subtree:

AttributeValuesEffect
data-palettewarm (default), cool, slate, forest, ocean, monohue and chroma set; every lightness value stays as audited
data-darkblacktrue-black dark surfaces for OLED screens
data-contrastmoredarker secondary text, stronger borders, 3px focus ring; also applied automatically for prefers-contrast: more
data-densitycompact, comfortablescales spacing and control heights; targets never drop below 24px
data-radiusnone, sm, lg, fullscales every corner

All of these persist through the JS setPreference(name, value) export and are applied before first paint by theme_script. To re-theme a subtree with custom hues, give it data-palette="custom" (any name) and override the hue variables on it: derived tokens are recomputed on any element carrying one of these attributes.

Auditing a palette

mix sink.audit boots the kitchen sink in headless Chrome and measures every token pairing for each preset in light and dark, with and without data-contrast="more". It fails if any text pairing is under 4.5:1, any non-text pairing under 3:1, or two semantic colours land within ΔE 15 under simulated protan, deutan or tritan vision. The kitchen sink's Themes page has a live builder that runs the same audit as you drag the hue sliders and prints the override block to paste.

Development

mix deps.get
mix dev # kitchen sink at http://localhost:4000
mix test # component render tests
mix assets.test # JavaScript unit tests (search matcher)
mix sink.audit # palette contrast and colour-vision audit (needs Chrome)
mix sink.smoke # browser smoke tests for every hook: real clicks and key presses (needs Chrome)

The kitchen sink under dev/ renders every component in every variant, with a theme toggle, and doubles as the manual accessibility test bed. Its CSS is deliberately unlayered to prove that consumer styles override the library.

See docs/browser-support.md for the feature support matrix and fallbacks.

Release checks

GitHub Actions runs formatting, compilation, ExUnit and JavaScript tests on Elixir 1.18/OTP 27 and Elixir 1.20/OTP 29. It also builds docs with warnings as errors, validates the Hex archive, installs that archive in a fresh Phoenix app without optional Markdown dependencies, and runs Chrome interaction and palette-contrast checks. CI does not publish packages.

To run the package installation check locally:

mix archive.install hex phx_new 1.8.9 --force
bash dev/release/check_consumer.sh

The check creates and removes a temporary app, renders a real LiveView, and bundles both CSS and JavaScript from the archive. It requires Python 3.12+ and network access for fresh dependencies.