Phoenix LiveView component library mirroring shadcn/ui — visually faithful, LiveView-native.
39 components, 22 JS hooks, 3-mode state ownership (client/hybrid/server).
Add phx_shadcn to your dependencies in mix.exs:
def deps do
[
{:phx_shadcn, "~> 0.1.0"},
{:igniter, "~> 0.5"} # optional, enables automatic setup
]
endmix igniter.install phx_shadcnThis patches your web module, CSS, and JavaScript automatically. Done.
If you prefer not to use Igniter, follow these steps:
In your lib/my_app_web.ex, update html_helpers:
defp html_helpers do
quote do
use Gettext, backend: MyAppWeb.Gettext
import Phoenix.HTML
# Exclude components that PhxShadcn replaces
import MyAppWeb.CoreComponents, except: [button: 1, input: 1, table: 1]
# Import all PhxShadcn components
use PhxShadcn
alias Phoenix.LiveView.JS
# ...
end
endPhxShadcn provides button/1, input/1, and table/1 (plus sub-components) that replace
Phoenix's default CoreComponents versions. All other CoreComponents (flash/1, header/1,
icon/1, simple_form/1, etc.) remain available.
Copy the shadcn theme into your assets/css/app.css. The full template is at
deps/phx_shadcn/priv/templates/phx_shadcn.css after running mix deps.get.
The key pieces:
/* Add after your @import "tailwindcss" line */
/* Scan phx_shadcn components for Tailwind classes */
@source "../../deps/phx_shadcn/lib";
/* shadcn theme — colors, radius, animations, custom variants */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* ... see full template for all colors, radius, animations */
}
/* Light/dark color values */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* ... see full template */
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* ... see full template */
}
/* Required custom variants */
@custom-variant data-open { ... }
@custom-variant data-closed { ... }
@custom-variant data-checked { ... }
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant dark (&:where(.dark, .dark *));
/* Base body styles */
body {
@apply bg-background text-foreground;
}The @source directive is critical — without it, Tailwind won't see the classes used inside
PhxShadcn components.
In assets/js/app.js:
import { hooks as phxShadcnHooks, PhxShadcn } from "phx_shadcn";
// Optional: expose vanilla JS helpers for inline onclick handlers
PhxShadcn.init();
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...phxShadcnHooks, /* your hooks */ },
// ...
});One spread covers all interactive components. Hook names are prefixed with PhxShadcn to
avoid clashes with your own hooks.
Add class="dark" to your <html> tag. The theme uses the .dark class strategy
(not prefers-color-scheme), so you control it however you like — a toggle, a cookie,
user preference, etc.
39 components across 3 tiers. All complete except NavigationMenu and some T4 composites.
| Component | Tier | Status | Notes |
|---|---|---|---|
| Badge | T1 | done | Variants: default, secondary, destructive, outline |
| Button | T1 | done | Variants + sizes. phx-click-loading styles. |
| Card | T1 | done | Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
| Separator | T1 | done | Horizontal / vertical |
| Skeleton | T1 | done | Pulsing placeholder |
| Alert | T1 | done | Alert, AlertTitle, AlertDescription. Variants: default, destructive |
| Avatar | T1 | done | Avatar, AvatarImage, AvatarFallback, AvatarGroup |
| AspectRatio | T1 | done | CSS-only ratio wrapper |
| Label | T1 | done | Styled <label> with for support |
| Input | T1 | done | Styled <input> with form integration |
| Textarea | T1 | done | Styled <textarea> |
| Table | T1 | done | Table, TableHeader, TableBody, TableRow, TableHead, TableCell, etc. |
| Breadcrumb | T1 | done | BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator |
| Pagination | T1 | done | PaginationContent, PaginationItem, PaginationLink, PaginationEllipsis |
| Checkbox | T1 | done | Native <input type="checkbox"> with custom styling. Form integration. |
| Accordion | T2 | done | Single/multiple, collapsible, 3 state modes, animation |
| Collapsible | T2 | done | Reuses Accordion's JS hook |
| Tabs | T2 | done | 3 state modes + patch mode (URL-driven). Roving tabindex. |
| Toggle | T2 | done | Variants: default/outline. Sizes: default/sm/lg. |
| ToggleGroup | T2 | done | Single/multiple selection, event delegation |
| Switch | T2 | done | Track + thumb, form integration via hidden input |
| RadioGroup | T2 | done | Arrow-key nav, role="radiogroup", form integration |
| Progress | T2 | done | Determinate + indeterminate. role="progressbar". |
| Slider | T2 | done | Drag + arrow keys, min/max/step |
| ScrollArea | T2 | done | Custom styled scrollbar |
| Form + FormField | T2 | done | Composable primitives + convenience wrapper |
| Dialog | T3 | done | Native <dialog>, focus trap, scroll lock |
| AlertDialog | T3 | done | Like Dialog but no backdrop dismiss, role="alertdialog" |
| Sheet | T3 | done | Side-sliding panel (top/right/bottom/left) |
| Popover | T3 | done | Floating UI positioning with flip/shift |
| Tooltip | T3 | done | Hover/focus triggered, arrow element |
| HoverCard | T3 | done | Hover-triggered popover |
| DropdownMenu | T3 | done | Floating + roving focus + typeahead. Checkbox/radio items. |
| ContextMenu | T3 | done | Right-click triggered, sub-menus |
| Menubar | T3 | done | Multi-menu coordination, hover-to-switch |
| Select | T3 | done | Custom styled select, typeahead, form integration |
| InputOTP | T3 | done | Single <input> overlaid on visual slots. Pattern presets. |
| Toast | T3 | done | Sonner-style stacked toasts, swipe-to-dismiss |
| NavigationMenu | T3 | planned | Complex hover timing, viewport animation |
| Command | T4 | planned | Search/filter UI (like cmdk) |
| Calendar | T4 | planned | Date grid, range selection |
| Sidebar | T4 | planned | Responsive sidebar with mobile sheet |
| Carousel | T4 | planned | Touch/swipe, snap points |
| Resizable | T4 | planned | Drag-to-resize panels |
Every interactive component supports 3 state modes, determined by which assigns you pass:
| Mode | Assigns | Behavior |
|---|---|---|
| Client | (none) | Pure JS toggling. Server is unaware. Zero latency. |
| Hybrid | on_value_change |
JS toggles instantly, then pushes event to server. |
| Server | value |
Server owns the state. Clicks push events, server re-renders. |
<%!-- Client-only --%>
<.accordion id="faq" type="single" collapsible>
...
</.accordion>
<%!-- Hybrid --%>
<.accordion id="faq" type="single" collapsible on_value_change="accordion:change">
...
</.accordion>
<%!-- Server-controlled --%>
<.accordion id="faq" type="single" collapsible value={@open_value} on_value_change="accordion:change">
...
</.accordion>The on_value_change / on_open_change attr accepts either a string (event name) or a Phoenix.LiveView.JS struct.
String callbacks push the event to your LiveView with a standardized payload:
# Accordion — on_value_change="accordion:change"
def handle_event("accordion:change", %{"id" => id, "value" => value, "action" => action}, socket)| Key | Type | Description |
|---|---|---|
id |
string | The component's DOM id |
value |
string or list | The affected item value(s) |
action |
"open" or "close" |
What happened |
Pass a Phoenix.LiveView.JS struct for client-side command chains without a server round-trip:
<.accordion
id="faq"
type="single"
collapsible
on_value_change={JS.dispatch("my-custom-event", to: "#analytics")}
/>The server can command any interactive component via push_event/3:
# Open a specific accordion item
push_event(socket, "phx_shadcn:command", %{id: "faq", command: "open", value: "q1"})
# Close it
push_event(socket, "phx_shadcn:command", %{id: "faq", command: "close", value: "q1"})
# Toggle
push_event(socket, "phx_shadcn:command", %{id: "faq", command: "toggle", value: "q1"})Control components from HEEx templates using chainable %Phoenix.LiveView.JS{} structs:
alias PhxShadcn.JS, as: SJS
# Accordion / Collapsible
<button phx-click={SJS.open("my-accordion", "q1")}>Open Q1</button>
<button phx-click={SJS.close("my-accordion", "q1")}>Close Q1</button>
# Toggle / Switch
<button phx-click={SJS.press("bold-toggle")}>Press</button>
<button phx-click={SJS.toggle("my-switch")}>Toggle</button>
# Overlays
<button phx-click={SJS.show("my-dialog")}>Open Dialog</button>
<button phx-click={SJS.hide("my-popover")}>Close Popover</button>
# Chain with LiveView JS commands
<button phx-click={JS.push("track") |> SJS.open("faq", "q1")}>Track & Open</button>For non-LiveView JavaScript (Alpine, Stimulus, inline handlers):
import { PhxShadcn } from "phx_shadcn/priv/static/phx-shadcn.js";
PhxShadcn.open("my-accordion", "q1")
PhxShadcn.press("bold-toggle")
PhxShadcn.show("my-dialog")
PhxShadcn.set("my-progress", 75)Components listen for inbound events and emit outbound events:
// Inbound (command)
el.dispatchEvent(new CustomEvent("phx-shadcn:open", { detail: { value: "q1" } }));
// Outbound (notification, past tense)
el.addEventListener("phx-shadcn:opened", (e) => {
console.log("opened:", e.detail.id, e.detail.value);
});All components use cn() (powered by TailwindMerge) — your class assign always wins:
<%!-- Your mt-8 overrides the component's default margin --%>
<.button class="mt-8">Click me</.button>
<%!-- Works with conditional classes too --%>
<.card class={["mt-4", @highlighted && "ring-2 ring-primary"]}>
...
</.card>MIT