Skip to content

khemmanat/aven_ui

Repository files navigation

AvenUI — 24 Phoenix LiveView Components

Even if AvenUI stopped being maintained tomorrow, your app keeps working — because mix aven_ui.add copies the code into your project. You own it. No paywalls. Ever.

Free alternative to Petal. MIT licensed. Tailwind v4 native.


Why AvenUI?

  • 24 accessible Phoenix LiveView components
  • shadcn-style installer — mix aven_ui.add button badge copies source into your app
  • mix aven_ui.new scaffolds a fresh Phoenix 1.8 app with AvenUI pre-wired
  • Tailwind v4 / Phoenix 1.8 native — CSS-first setup, no tailwind.config.js required
  • Full dark mode via design tokens, without class flicker
  • Form-native inputs for Phoenix.HTML.FormField and Ecto changesets
  • Rich data UI: combobox, date_picker, and data_table
  • Minimal JS hooks only where interaction is actually needed

New in 0.2.x

  • combobox — searchable select with keyboard navigation
  • date_picker — single-date and range picker with server-rendered calendar
  • data_table — filter bar, server-side sort, pagination, and active filter chips
  • mix aven_ui.new — Phoenix 1.8 project generator with AvenUI pre-installed
  • Tailwind v4 migration — CSS-first setup with native @theme tokens

Accessibility

Every component ships with accessibility baked in:

  • role and aria-* attributes where relevant
  • Keyboard navigation for dropdowns, comboboxes, tabs, and date pickers
  • Focus management for modal dialogs
  • Screen-reader friendly flash toasts via role="status"
  • Semantic table, form, and navigation markup

Installation

Option A — Start a new app with AvenUI

mix aven_ui.new my_app
cd my_app
mix phx.server

This generates a Phoenix 1.8 app with:

  • AvenUI installed
  • all 24 components added
  • Tailwind v4 CSS wired
  • AvenUIHooks connected in app.js
  • starter dashboard layout with dark mode toggle

Option B — Add AvenUI to an existing Phoenix app

1. Add the dependency

# mix.exs
def deps do
  [
    {:aven_ui, "~> 0.2.3", hex: :aven_ui}
  ]
end

If you prefer GitHub:

{:aven_ui, "~> 0.2.3", github: "khemmanat/aven_ui"}

2. Install components

mix deps.get

# Add specific components
mix aven_ui.add button badge alert card input tabs modal

# Or add everything
mix aven_ui.add --all

# Dry run — see what would be copied
mix aven_ui.add --all --dry-run

This copies component modules into lib/my_app_web/components/ui/, plus:

  • assets/css/avenui.css
  • assets/js/hooks/aven_ui.js

3. Import your copied components in web.ex

# lib/my_app_web.ex
defp html_helpers do
  quote do
    import MyAppWeb.UI.Accordion
    import MyAppWeb.UI.Alert
    import MyAppWeb.UI.Avatar
    import MyAppWeb.UI.Badge
    import MyAppWeb.UI.Button
    import MyAppWeb.UI.Card
    import MyAppWeb.UI.CodeBlock
    import MyAppWeb.UI.Combobox
    import MyAppWeb.UI.DataTable
    import MyAppWeb.UI.DatePicker
    import MyAppWeb.UI.Dropdown
    import MyAppWeb.UI.EmptyState
    import MyAppWeb.UI.Input
    import MyAppWeb.UI.Kbd
    import MyAppWeb.UI.Modal
    import MyAppWeb.UI.Progress
    import MyAppWeb.UI.Separator
    import MyAppWeb.UI.Skeleton
    import MyAppWeb.UI.Spinner
    import MyAppWeb.UI.Stat
    import MyAppWeb.UI.Table
    import MyAppWeb.UI.Tabs
    import MyAppWeb.UI.Toast
    import MyAppWeb.UI.Toggle
  end
end

If you want to use AvenUI directly from the dependency instead of your copied modules, use AvenUI, :components is also available.

4. Add the CSS

/* assets/css/app.css */
@import "tailwindcss";
@import "./avenui.css";

No tailwind.config.js is needed with Tailwind v4.

5. Add the JS hooks

// assets/js/app.js
import { AvenUIHooks } from "./hooks/aven_ui";

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...AvenUIHooks, ...Hooks },
});

6. Render flash toasts in your root layout

<%!-- lib/my_app_web/components/layouts/root.html.heex --%>
<body>
  <%= @inner_content %>
  <.flash_group flash={@flash} />
</body>

Project structure

aven_ui/
├── mix.exs
├── lib/
│   ├── aven_ui.ex
│   └── aven_ui/
│       ├── data_table.ex
│       ├── helpers.ex
│       ├── components/
│       │   ├── accordion.ex
│       │   ├── alert.ex
│       │   ├── avatar.ex
│       │   ├── badge.ex
│       │   ├── button.ex
│       │   ├── card.ex
│       │   ├── code_block.ex
│       │   ├── combobox.ex
│       │   ├── data_table.ex
│       │   ├── date_picker.ex
│       │   ├── dropdown.ex
│       │   ├── empty_state.ex
│       │   ├── input.ex
│       │   ├── kbd.ex
│       │   ├── modal.ex
│       │   ├── progress.ex
│       │   ├── separator.ex
│       │   ├── skeleton.ex
│       │   ├── spinner.ex
│       │   ├── stat.ex
│       │   ├── table.ex
│       │   ├── tabs.ex
│       │   ├── toast.ex
│       │   └── toggle.ex
│       └── mix/tasks/
│           ├── add.ex
│           ├── list.ex
│           └── new.ex
├── assets/
│   ├── css/avenui.css
│   └── js/hooks/index.js
└── storybook/

Component inventory

Forms

  • Input / Select
  • Combobox
  • Date Picker
  • Toggle

Actions and navigation

  • Button
  • Dropdown
  • Tabs
  • Accordion

Data and layout

  • Table
  • Data Table
  • Card
  • Stat

Feedback and overlays

  • Alert
  • Toast / Flash
  • Modal

Display and utility

  • Avatar
  • Badge
  • Code Block
  • Empty State
  • Kbd
  • Progress
  • Separator
  • Skeleton
  • Spinner

Examples

Button

<.button>Deploy</.button>
<.button variant="secondary" size="sm">Cancel</.button>
<.button variant="danger" phx-click="delete" phx-value-id={@id}>Delete</.button>
<.button loading={@saving}>Saving...</.button>

Variants: primary secondary ghost danger outline link
Sizes: xs sm md lg xl

Input + Select

<.input field={@form[:email]} type="email" label="Email" hint="We'll never share this." />

<.input field={@form[:amount]} label="Amount">
  <:prefix>$</:prefix>
</.input>

<.select
  field={@form[:region]}
  label="Region"
  options={[{"Bangkok", "bangkok"}, {"Singapore", "singapore"}, {"Tokyo", "tokyo"}]}
  prompt="Choose..."
/>

Combobox

<.combobox
  id="country"
  field={@form[:country]}
  label="Country"
  placeholder="Search countries..."
  options={[
    %{value: "th", label: "Thailand"},
    %{value: "sg", label: "Singapore"},
    %{value: "jp", label: "Japan"}
  ]}
/>

<.combobox
  id="user"
  name="user_id"
  label="User"
  placeholder="Search users..."
  options={@users}
  selected={@user_id}
  on_search="search_users"
/>

Date Picker

<.date_picker
  id="birthday"
  name="birthday"
  label="Birthday"
  selected={@birthday}
  view_month={@dp_month}
  on_change="date_selected"
/>

<.date_picker
  id="booking"
  name="booking"
  label="Stay dates"
  mode="range"
  range_start={@check_in}
  range_end={@check_out}
  view_month={@dp_month}
  on_change="dates_selected"
/>
def handle_event("date_selected", %{"date" => date}, socket) do
  {:noreply, assign(socket, :birthday, Date.from_iso8601!(date))}
end

def handle_event("dp_prev_month_birthday", _, socket) do
  {:noreply, update(socket, :dp_month, &Date.beginning_of_month(Date.add(&1, -1)))}
end

def handle_event("dp_next_month_birthday", _, socket) do
  {:noreply, update(socket, :dp_month, &Date.beginning_of_month(Date.add(&1, 32)))}
end

Card

<.card>
  <:header>
    <.card_title>Server #3</.card_title>
    <.card_description>Last ping 2s ago</.card_description>
  </:header>
  <:body>
    <.progress value={72} label="CPU" show_value />
  </:body>
  <:footer>
    <.button size="sm">Restart</.button>
  </:footer>
</.card>

Modal

<.button phx-click="open_modal">Open</.button>

<.modal :if={@show_modal} id="confirm-modal">
  <:title>Delete project?</:title>
  <:description>This cannot be undone.</:description>
  <p>All data will be permanently deleted.</p>
  <:footer>
    <.button variant="ghost" phx-click="close_modal">Cancel</.button>
    <.button variant="danger" phx-click="confirm_delete">Delete</.button>
  </:footer>
</.modal>
def handle_event("open_modal", _, socket), do: {:noreply, assign(socket, show_modal: true)}
def handle_event("close_modal", _, socket), do: {:noreply, assign(socket, show_modal: false)}

Dropdown

<.dropdown id="actions-menu">
  <:trigger>
    <.button variant="secondary" size="sm">Options <.dropdown_chevron /></.button>
  </:trigger>
  <.dropdown_label>Actions</.dropdown_label>
  <.dropdown_item phx-click="edit">Edit</.dropdown_item>
  <.dropdown_separator />
  <.dropdown_item variant="danger" phx-click="delete">Delete</.dropdown_item>
</.dropdown>

Tabs

<.tabs active={@tab} patch="/dashboard" param="tab">
  <:tab id="overview">Overview</:tab>
  <:tab id="settings">Settings</:tab>
  <:panel id="overview"><.overview /></:panel>
  <:panel id="settings"><.settings_form /></:panel>
</.tabs>

Variants: underline pills boxed

Table

<.table rows={@deployments} sort_field={@sort_by} sort_dir={@sort_dir}>
  <:col :let={row} label="Commit" field="sha" sortable>
    <code class="font-mono text-xs"><%= row.sha %></code>
  </:col>
  <:col :let={row} label="Status">
    <.badge variant={status_color(row.status)}><%= row.status %></.badge>
  </:col>
  <:action :let={row}>
    <.button size="xs" variant="ghost" phx-click="restart" phx-value-id={row.id}>
      Restart
    </.button>
  </:action>
</.table>

<.pagination page={@page} total_pages={@total_pages} phx-click="paginate" />

Data Table

<.data_table
  id="users-table"
  rows={@rows}
  table={@table}
  title="Users"
  on_filter="dt_filter"
  on_sort="dt_sort"
  on_paginate="dt_paginate"
>
  <:filter>
    <.dt_search name="search" placeholder="Search users..." />
    <.dt_select
      name="status"
      prompt="All statuses"
      options={[{"Active", "active"}, {"Inactive", "inactive"}]}
    />
  </:filter>

  <:toolbar>
    <.button size="sm" phx-click="new_user">New user</.button>
  </:toolbar>

  <:col :let={u} label="Name" field="name" sortable><%= u.name %></:col>
  <:col :let={u} label="Email" field="email" sortable><%= u.email %></:col>
  <:action :let={u}>
    <.button size="xs" variant="ghost" phx-click="edit" phx-value-id={u.id}>Edit</.button>
  </:action>
</.data_table>
alias AvenUI.DataTable

def mount(_, _, socket) do
  {:ok, socket |> assign(:table, DataTable.new(per_page: 20)) |> load_rows()}
end

def handle_event("dt_filter", params, socket) do
  table =
    socket.assigns.table
    |> DataTable.set_filter(params)
    |> DataTable.reset_page()

  {:noreply, socket |> assign(:table, table) |> load_rows()}
end

def handle_event("dt_sort", %{"field" => field}, socket) do
  table = DataTable.toggle_sort(socket.assigns.table, field)
  {:noreply, socket |> assign(:table, table) |> load_rows()}
end

def handle_event("dt_paginate", %{"page" => page}, socket) do
  table = DataTable.set_page(socket.assigns.table, String.to_integer(page))
  {:noreply, socket |> assign(:table, table) |> load_rows()}
end

Toast / Flash

<.flash_group flash={@flash} />
socket |> put_flash(:info, "Deployment complete!")
socket |> put_flash(:error, "Connection failed.")

Accordion

<.accordion id="faq">
  <:item title="Is AvenUI free?">Yes. MIT licensed.</:item>
  <:item title="Does it support dark mode?" open>
    Yes — via CSS variables and Tailwind v4 theme tokens.
  </:item>
</.accordion>

Avatar

<.avatar initials="KN" />
<.avatar initials="KN" size="lg" color="green" />
<.avatar src="https://..." alt="Khemmanat" />

<.avatar_group>
  <.avatar initials="KN" />
  <.avatar initials="AB" color="amber" />
  <.avatar initials="+3" color="gray" />
</.avatar_group>

Stat

<div class="grid grid-cols-3 gap-4">
  <.stat label="Deploys today" value="24" change="+8" trend="up" />
  <.stat label="Avg response" value="142" suffix="ms" change="+12ms" trend="down" />
  <.stat label="Uptime (30d)" value="99.97" suffix="%" />
</div>

Utility components

<.progress value={72} label="Storage" show_value color="blue" />
<.skeleton class="h-4 w-48" />
<.spinner size="lg" class="text-avn-purple" />
<.separator label="or continue with" />
<.kbd></.kbd><.kbd>K</.kbd>
<.empty_state title="No results" description="Try a different search." />
<.code_block lang="elixir" copyable>def hello, do: "world"</.code_block>

JS Hooks

AvenUI exports 10 lightweight hooks:

Hook Purpose
Flash Auto-dismiss with pause on hover
Dropdown Keyboard nav, outside click close
Modal Focus trap, Escape close, scroll lock
Tooltip Position-aware tooltip
AutoResize Growing textarea
CopyToClipboard Clipboard API with feedback
InfiniteScroll Load-more on sentinel visible
ScrollTop Smooth scroll on LiveView patch
AvenUICombobox Open, search, select, keyboard nav
AvenUIDatePicker Date-picker open/close and clear wiring
<button phx-hook="Tooltip" id="info-btn" data-tooltip="More info">?</button>

<div phx-hook="Dropdown" id="my-menu">
  <button data-avn-dropdown-trigger>Open</button>
  <div data-avn-dropdown-menu hidden>
    <button data-avn-dropdown-item>Edit</button>
  </div>
</div>

Storybook

Run the interactive component docs locally:

cd storybook
mix deps.get
mix phx.server

Then open http://localhost:4000.


Roadmap

  • Command palette
  • Drawer / slideout panel
  • Chart hooks
  • Multi-select
  • File upload

License

MIT — free to use, modify, and distribute.