Cinder UI

CIHex.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.

Installation

Prerequisites

You need an existing Phoenix 1.7+ 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.3", runtime: Mix.env() == :dev},
    # ...
  ]
end

Configure Tailwind in config/config.exs:

config :tailwind,
  version: "4.1.12",
  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.1.0"},
    # Optional but recommended — required for the <.icon /> component
    {:lucide_icons, "~> 2.0"},
    # ...
  ]
end

Fetch dependencies:

mix deps.get

3. Run the installer

Cinder UI includes a Mix task that sets up CSS, JavaScript hooks, and Tailwind plugins automatically:

mix cinder_ui.install

This will:

The installer auto-detects your package manager (npm, pnpm, yarn, or bun). To specify one explicitly:

mix cinder_ui.install --package-manager pnpm

To re-run without overwriting customized files:

mix cinder_ui.install --skip-existing

To only (re)copy cinder_ui.css and cinder_ui.js without patching app.css/app.js:

mix cinder_ui.install --skip-patching

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

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>

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="levi" label="Levi Buzolic" description="Engineering" />
        <:option value="mira" label="Mira Chen" description="Design" />
        <: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.

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")

Icons (Optional, Recommended)

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 assets/css/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