Cinder UI

CI Hex.pm

shadcn/ui components for Phoenix + LiveView.

Cinder UI is a Hex-oriented component library that ports shadcn/ui design patterns, classes, tokens, and compositional structure into Elixir function components.

Component documentation links include screenshot previews when shared. See static exports for hosting configuration.

Installation

Prerequisites

You need an existing Phoenix 1.7.24+ or 1.8.9+ project. If you don't have one yet:

mix phx.new my_app
cd my_app

1. Set up Tailwind CSS

Cinder UI requires Tailwind CSS v4+. New Phoenix projects generated with mix phx.new include Tailwind by default — if yours already has it, skip to step 2.

Add the Tailwind plugin to your dependencies in mix.exs:

defp deps do
  [
    {:tailwind, "~> 0.5.2", runtime: Mix.env() == :dev},
    # ...
  ]
end

Configure Tailwind in config/config.exs:

config :tailwind,
  version: "4.3.3",
  my_app: [
    args: ~w(
      --input=assets/css/app.css
      --output=priv/static/assets/app.css
    ),
    cd: Path.expand("..", __DIR__)
  ]

Add the Tailwind watcher in config/dev.exs:

config :my_app, MyAppWeb.Endpoint,
  watchers: [
    tailwind: {Tailwind, :install_and_run, [:my_app, ~w(--watch)]}
  ]

Add Tailwind to the deployment alias in mix.exs:

defp aliases do
  [
    "assets.deploy": [
      "tailwind my_app --minify",
      "esbuild my_app --minify",
      "phx.digest"
    ]
  ]
end

Install Tailwind and fetch dependencies:

mix deps.get
mix tailwind.install

Set up assets/css/app.css:

@import "tailwindcss";

If your assets/js/app.js imports CSS (import "../css/app.css"), remove that line — Tailwind handles CSS compilation separately.

2. Add Cinder UI

Add the dependency to your mix.exs:

defp deps do
  [
    {:cinder_ui, "~> 0.2.5"},
    # Optional but recommended — required for the <.icon /> component
    {:lucide_icons, "~> 2.4"},
    # ...
  ]
end

Fetch dependencies:

mix deps.get

3. Run the installer

Cinder UI includes a Mix task that wires its CSS and JavaScript hooks into your Phoenix app:

mix cinder_ui.install

This references Cinder UI's CSS and JS directly from deps/cinder_ui — nothing is copied into your project, so the assets stay in sync automatically when you upgrade the dependency. It will:

There is no npm package to install — the tailwindcss-animate animation utilities are inlined directly into Cinder UI's CSS.

Wiring it up by hand

The CSS edit is just two lines. If you'd rather not run the task, add them to assets/css/app.css yourself (after @import "tailwindcss";):

@source "../../deps/cinder_ui";
@import "../../deps/cinder_ui/priv/templates/cinder_ui.css";

Then import the hooks in assets/js/app.js and merge them into your LiveView hooks:

import { CinderUIHooks } from "cinder_ui"
// ...
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...CinderUIHooks },
  // ...
})

Pass --skip-patching to run the task without touching app.css/app.js.

4. Configure your app

Add use CinderUI to your app's html_helpers in lib/my_app_web.ex:

defp html_helpers do
  quote do
    use Phoenix.Component
    use CinderUI
    # ...
  end
end

Existing projects with CoreComponents

Phoenix generates a CoreComponents module with functions like button/1, input/1, table/1, flash/1, card/1, and label/1 that overlap with Cinder UI components. Cinder UI provides replacements for all of these.

Recommended: Remove conflicting CoreComponents

The cleanest approach is to delete the overlapping functions (button/1, card/1, flash/1, flash_group/1, input/1, label/1, table/1, and icon/1) from your CoreComponents module and let Cinder UI's versions take over. This gives you a single, consistent component API with no namespace prefixes needed. If your CoreComponents only contains the default Phoenix-generated functions, you can remove the module entirely and replace import MyAppWeb.CoreComponents with use CinderUI in your html_helpers.

If you'd prefer to keep your existing CoreComponents alongside Cinder UI, you have several options:

Option A: Exclude conflicting components

Use the except option to skip components that conflict with your CoreComponents:

use CinderUI, except: [:button, :card, :flash, :flash_group, :input, :label, :table]

Excluded components are still available via the CinderUI.UI facade (see Option B) or their full module path (e.g., <CinderUI.Components.Actions.button>).

Option B: Use the CinderUI.UI facade

Alias the facade module for zero-conflict namespaced access to every component:

alias CinderUI.UI

Then use components with the UI. prefix:

<UI.button>Click me</UI.button>
<UI.autocomplete id="search" name="q" value="">
  <:option value="foo" label="Foo" />
</UI.autocomplete>
<UI.icon name="chevron-down" class="size-4" />

This can be combined with Option A — use except for the components that conflict, and access excluded components via UI.button, UI.input, etc.

Option C: Selectively import specific modules

import CinderUI.Components.Actions
import CinderUI.Components.Forms

Typography aliases such as <.h1> and <.lead> live in CinderUI.Components.Typography and are not imported by default because their short names are more likely to collide with application components. Import them manually when you want local shorthand components:

import CinderUI.Components.Typography

Or opt in globally from your web helpers:

use CinderUI, typography: true

For namespaced access without importing the aliases into the local component scope:

alias CinderUI.Components.Typography
<Typography.h1>Realtime payments need boring interfaces</Typography.h1>

5. Start building

Start your Phoenix server:

mix phx.server
# or from the repo root:
./bin/demo --port 4001

Try a component in any template:

<.button>Click me</.button>

Typography Recipes

typography/1 provides a small set of Shadcn-inspired text treatments without adding a prose renderer or client-side dependency. Variants use the same Tailwind token classes as the rest of Cinder UI and render semantic tags by default.

<article class="max-w-2xl">
  <.typography variant={:h1}>Realtime payments need boring interfaces</.typography>
  <.typography variant={:lead} class="mt-4">
    Operators need clear hierarchy, calm defaults, and copy that survives dense workflows.
  </.typography>
  <.typography>
    Inline values like <.typography variant={:inline_code}>90d</.typography>
    stay aligned with the active theme tokens.
  </.typography>
</article>

When you opt in to typography aliases, the same styles are available as named components:

<.h1>Realtime payments need boring interfaces</.h1>
<.lead class="mt-4">Operators need clear hierarchy.</.lead>
<.p>Inline values like <.inline_code>90d</.inline_code> stay aligned.</.p>

Example data

The docs and demo share 24 Futurama characters, including the Planet Express crew and their wider circle of contacts. Autocomplete, selects, command lists, avatars, tables, and recipes use the same records. Try robot or captain in the search examples; results can match a role as well as a name.

Copyable examples use @characters (the full roster), @crew (Planet Express), and @orders (delivery orders). Supply these assigns from your own application. A character has this shape:

%{
  id: "fry",
  label: "Philip J. Fry",
  description: "Delivery boy",
  group: "Planet Express",
  keywords: ["human", "courier"],
  avatar: "/images/fry.webp",
  email: "fry@planetexpress.example",
  status: :active
}

The complete fixture lives in sample_data.ex and is used only by documentation tooling. Contact details and account states are fictional. Country and date examples retain real-world values appropriate to their controls. Character portraits are bundled locally for stable previews; portrait sources and copyright notes are recorded separately from the library’s MIT license.

Forms and Validation

CinderUI.Components.Forms supports both simple field wrappers and more explicit field composition for validated LiveView forms.

Prefer the shorthand :label slot for standard labels. Use raw :label content only when you need custom label markup.

Basic field usage:

<.field>
  <:label for="project-name">Project name</:label>
  <.input id="project-name" name="project[name]" />
  <:description>Visible to your team in dashboards and alerts.</:description>
</.field>

Explicit composition with validation messaging:

<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6">
  <.field invalid={@form[:owner].errors != []}>
    <:label for={@form[:owner].id}>Owner</:label>

    <.field_control>
      <.autocomplete
        id={@form[:owner].id}
        name={@form[:owner].name}
        value={@form[:owner].value}
        aria-label="Owner"
      >
        <:option value="fry" label="Philip J. Fry" description="Delivery boy" />
        <:option value="leela" label="Turanga Leela" description="Ship captain" />
        <:empty>No matching teammates.</:empty>
      </.autocomplete>
    </.field_control>

    <.field_description>Pick the teammate responsible for the workspace.</.field_description>
    <.field_error :for={{msg, _opts} <- @form[:owner].errors}>{msg}</.field_error>
  </.field>

  <.button type="submit">Save</.button>
</.form>

autocomplete/1 keeps the last committed selection until the user confirms a new option. Typing implicitly highlights the first visible match so Enter accepts it, while Escape or clicking away restores the prior selection.

For server search, set filter={false} to show all supplied options in the server's order. Both input and popup variants support this flag. The default, filter={true}, matches the query against option labels and keywords.

<.autocomplete id="place-search" name="place_id" filter={false}
  phx-keyup="search-places" phx-debounce="300" aria-label="Search places">
  <:option :for={place <- @places} value={place.id} label={place.label} />
</.autocomplete>

Handle "search-places" in your LiveView and update @places with the results. The search event includes the typed text as "value". The hidden place_id input submits the selected place ID.

For on-demand search, add search_event="search-owners" to the autocomplete. Enter in the text field or the built-in Search button sends that event with %{"query" => query}; typing stays local. Use arrow keys then Enter, or click an option, to select a result. search_label customizes the button text and phx-target={@myself} targets a LiveComponent. Both variants support this mode.

The autocomplete examples show LiveView initialization and a search handler that updates @owners. The option slots loop over those results. Replace Accounts.search_owners/1 with your application’s query.

The demo's server-search examples simulate latency and match names and roles. Try robot, captain, or doctor to see loading, results, and selection.

Custom label markup remains available when you need richer label content:

<.field>
  <:label>
    <.field_label>
      <.label for="workspace-name">Workspace name</.label>
      <span class="text-muted-foreground text-xs">Shown in team switchers.</span>
    </.field_label>
  </:label>
  <.input id="workspace-name" name="workspace[name]" />
</.field>

Available field helpers:

Interactive Commands

Interactive components that ship with Cinder UI hooks now share a small command surface through the cinder-ui:command custom event. You can drive that surface directly from LiveView with CinderUI.JS.

For overlay-style components that use the shipped hooks, the current baseline behavior is:

Supported commands depend on the component, but the common baseline is:

Some input-style components also support:

Current limitation:

LiveView example:

<button phx-click={CinderUI.JS.open(to: "#account-dialog")}>
  Open dialog
</button>

<button phx-click={CinderUI.JS.clear(to: "#owner-autocomplete")}>
  Clear owner
</button>

Raw event example:

const dialog = document.querySelector("[data-slot='dialog']")

dialog?.dispatchEvent(
  new CustomEvent("cinder-ui:command", {
    detail: { command: "open" },
  }),
)

If you import CinderUI from assets/js/cinder_ui.js, you can also dispatch through the helper:

import { CinderUI, CinderUIHooks } from "./cinder_ui"

CinderUI.dispatchCommand(document.querySelector("[data-slot='select']"), "toggle")

The optional icon integration requires Elixir 1.18+.

CinderUI.Icons.icon/1 dispatches to lucide_icons.

Example:

<.icon name="chevron-down" class="size-4" />
<.icon name="loader_circle" class="size-4 animate-spin" />

If lucide_icons is missing and <.icon /> is used, Cinder UI raises an error.

Theming and Style Overrides

Cinder UI uses shadcn-style CSS variables (--background, --foreground, --primary, etc.) and dark mode with .dark.

Configure variables (shadcn-style)

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.54 0.22 262);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --destructive-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --radius: 0.75rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.72 0.18 262);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --destructive-foreground: oklch(0.985 0 0);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
}

Set your preferred corner scale by changing --radius; component classes (rounded-md, rounded-lg, etc.) derive from that value through the Tailwind token mapping in Cinder UI's CSS (deps/cinder_ui/priv/templates/cinder_ui.css).

API Docs

The docs site is the source of truth for component coverage, examples, and current behavior. Every component module also includes in-source docs and usage examples. Generate docs with:

mix docs

Feasibility Notes

A subset of shadcn components rely on browser-first stacks (Radix primitives, complex keyboard navigation, chart engines, or heavy client state). For these, Cinder UI provides either progressive LiveView hook behavior or a scaffold component with stable API + styling.

Attribution and Third-Party Notices

Cinder UI is built on the shoulders of giants, leveraging the awesome work from these projects:

Thank you to the maintainers and contributors.

For third-party license details and links to upstream license texts, see: THIRD_PARTY_NOTICES.md

Contributing

See CONTRIBUTING.md for contributor setup, quality gates, testing, release workflow, and docs/site build maintenance.

License

MIT