Visual identity, status indicators, avatars, chat UI, and theme controls.

Table of Contents


icon

Lucide SVG sprite icon. Generate the sprite with mix phia.icons.

Sizes: :xs (12px), :sm (16px), :md (20px, default), :lg (24px)

<.icon name="check" />
<.icon name="alert-triangle" size="lg" class="text-destructive" />
<.icon name="loader" size="sm" class="animate-spin text-primary" />
<.icon name="arrow-up-right" size="xs" class="text-green-500" />

Common names: home, settings, user, log-out, search, plus, trash, pencil, check, x, chevron-right, chevron-down, bar-chart-2, file-text, bell, star, heart, shield, lock, key, mail, phone, calendar, clock, tag, upload, download


badge

Status and category labels.

Variants: default, secondary, destructive, outline

<.badge>Active</.badge>
<.badge variant="secondary">Draft</.badge>
<.badge variant="destructive">Failed</.badge>
<.badge variant="outline">Archived</.badge>
# Dynamic variant based on status
defp status_variant("active"),   do: "default"
defp status_variant("draft"),    do: "secondary"
defp status_variant("failed"),   do: "destructive"
defp status_variant(_),          do: "outline"
<%!-- In a table cell --%>
<.table_cell>
  <.badge variant={status_variant(@order.status)}>
    <%= String.capitalize(@order.status) %>
  </.badge>
</.table_cell>

avatar

Circular profile image with initials fallback. 5 sizes: :xs, :sm, :md, :lg, :xl.

Sub-components: avatar_image/1, avatar_fallback/1

<%!-- Image with fallback --%>
<.avatar size="md">
  <.avatar_image src={@user.avatar_url} alt={@user.name} />
  <.avatar_fallback name={@user.name} />
</.avatar>

<%!-- Initials only --%>
<.avatar size="lg">
  <.avatar_fallback name="Alice Martin" />
</.avatar>

<%!-- Different sizes --%>
<.avatar size="xs"><.avatar_fallback name="XS" /></.avatar>
<.avatar size="sm"><.avatar_fallback name="SM" /></.avatar>
<.avatar size="md"><.avatar_fallback name="MD" /></.avatar>
<.avatar size="lg"><.avatar_fallback name="LG" /></.avatar>
<.avatar size="xl"><.avatar_fallback name="XL" /></.avatar>

avatar_group

Overlapping avatar row with +N overflow badge.

Attrs: max (integer, default 4)

<%!-- Show up to 5, then +N more --%>
<.avatar_group max={5}>
  <.avatar :for={user <- @team_members}>
    <.avatar_image src={user.avatar_url} alt={user.name} />
    <.avatar_fallback name={user.name} />
  </.avatar>
</.avatar_group>

<%!-- With tooltip on hover for names --%>
<.avatar_group max={4}>
  <.tooltip :for={user <- @collaborators}>
    <:trigger>
      <.avatar size="sm">
        <.avatar_fallback name={user.name} />
      </.avatar>
    </:trigger>
    <:content><%= user.name %></:content>
  </.tooltip>
</.avatar_group>

activity_feed

Chronological event log with role="log" and aria-live="polite". Groups events by date. 6 activity types with distinct icons.

Sub-components: activity_group/1, activity_item/1

Activity types: mention, file, call, task, reaction, system

<.activity_feed>
  <.activity_group label="Today">
    <.activity_item
      type="mention"
      name="Alice Martin"
      description="mentioned you in Project Alpha discussion"
      timestamp="2 minutes ago"
    >
      <:avatar>
        <.avatar size="sm">
          <.avatar_image src="/images/alice.jpg" alt="Alice" />
          <.avatar_fallback name="Alice Martin" />
        </.avatar>
      </:avatar>
    </.activity_item>

    <.activity_item
      type="task"
      name="Bob Chen"
      description="completed task: Deploy to staging"
      timestamp="15 minutes ago"
    >
      <:avatar><.avatar size="sm"><.avatar_fallback name="Bob Chen" /></.avatar></:avatar>
    </.activity_item>

    <.activity_item
      type="file"
      name="Carol Davis"
      description="uploaded Design System v2.pdf"
      timestamp="1 hour ago"
    />
  </.activity_group>

  <.activity_group label="Yesterday">
    <.activity_item
      type="system"
      name="System"
      description="Deployment to production completed successfully"
      timestamp="March 4 at 11:45 PM"
    />
  </.activity_group>

  <:footer>
    <.button variant="ghost" size="sm" class="w-full" phx-click="load_more_activity">
      Load more
    </.button>
  </:footer>
</.activity_feed>

Streaming activity updates

def mount(_params, _session, socket) do
  if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "activity")
  {:ok, stream(socket, :activities, ActivityFeed.recent(50))}
end

def handle_info({:new_activity, activity}, socket) do
  {:noreply, stream_insert(socket, :activities, activity, at: 0)}
end

timeline

Vertical event timeline with CSS-only connector line. Use for order status, deployment history, and audit logs.

Sub-components: timeline_item/1 Statuses: complete, active, upcoming

<%!-- Order tracking --%>
<.timeline>
  <.timeline_item status="complete">
    <:icon><.icon name="check-circle" size="sm" class="text-green-500" /></:icon>
    <:content>
      <p class="font-medium">Order placed</p>
      <p class="text-sm text-muted-foreground">March 1 at 10:00 AM</p>
    </:content>
  </.timeline_item>
  <.timeline_item status="complete">
    <:icon><.icon name="package" size="sm" class="text-green-500" /></:icon>
    <:content>
      <p class="font-medium">Packed and shipped</p>
      <p class="text-sm text-muted-foreground">March 2 at 3:15 PM · FedEx #1234</p>
    </:content>
  </.timeline_item>
  <.timeline_item status="active">
    <:icon><.icon name="truck" size="sm" class="text-primary" /></:icon>
    <:content>
      <p class="font-medium text-primary">In transit</p>
      <p class="text-sm text-muted-foreground">Estimated delivery: March 5</p>
    </:content>
  </.timeline_item>
  <.timeline_item status="upcoming">
    <:icon><.icon name="home" size="sm" class="text-muted-foreground" /></:icon>
    <:content>
      <p class="font-medium text-muted-foreground">Delivered</p>
    </:content>
  </.timeline_item>
</.timeline>

chat_message

Full AI/human chat interface. Sub-components compose into a complete chat UI.

Sub-components: chat_container/1, chat_bubble/1, chat_suggestions/1, chat_input/1

<%!-- Complete chat UI --%>
<.chat_container id="ai-chat" class="h-[600px] flex flex-col">
  <div class="flex-1 overflow-y-auto p-4 space-y-4">
    <.chat_message role="assistant" id="msg-0">
      <.chat_bubble role="assistant" timestamp="2:30 PM">
        <:avatar>
          <.avatar size="sm">
            <.avatar_fallback name="AI" />
          </.avatar>
        </:avatar>
        Hello! I'm your AI assistant. How can I help you today?
      </.chat_bubble>
      <.chat_suggestions
        suggestions={["What can you do?", "Help me write a report", "Analyze my data"]}
        on_select="send_suggestion"
      />
    </.chat_message>

    <.chat_message :for={msg <- @messages} role={msg.role} id={"msg-#{msg.id}"}>
      <.chat_bubble role={msg.role} timestamp={format_time(msg.inserted_at)}>
        <:avatar :if={msg.role == "assistant"}>
          <.avatar size="sm"><.avatar_fallback name="AI" /></.avatar>
        </:avatar>
        <%= msg.content %>
      </.chat_bubble>
    </.chat_message>

    <div :if={@ai_typing} class="flex items-center gap-2 text-muted-foreground">
      <.spinner size="sm" /> AI is typing…
    </div>
  </div>

  <.chat_input
    id="chat-compose"
    on_submit="send_message"
    placeholder="Ask anything…"
    disabled={@ai_typing}
  />
</.chat_container>
def handle_event("send_message", %{"message" => text}, socket) when text != "" do
  user_msg = %{id: Ecto.UUID.generate(), role: "user", content: text, inserted_at: DateTime.utc_now()}
  socket = socket
    |> stream_insert(:messages, user_msg)
    |> assign(ai_typing: true)
  # Trigger async AI response
  send(self(), {:ask_ai, text})
  {:noreply, socket}
end

def handle_event("send_suggestion", %{"suggestion" => text}, socket) do
  handle_event("send_message", %{"message" => text}, socket)
end

dark_mode_toggle

Sun/moon icon toggle. Reads and writes localStorage['phia-mode']. Syncs with prefers-color-scheme. Adds/removes class="dark" on <html>.

Hook: PhiaDarkMode

<%!-- In topbar or header --%>
<.dark_mode_toggle id="theme-toggle" />
// app.js
import PhiaDarkMode from "./phia_hooks/dark_mode"
// hooks: { PhiaDarkMode }
<!-- Anti-FOUC: add to <head> before any stylesheet -->
<script>
  (function() {
    var mode = localStorage.getItem('phia-mode');
    if (mode === 'dark' || (!mode && matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>

theme_provider

Scoped CSS theme wrapper. Sets data-phia-theme on its wrapper <div>, so PhiaUI tokens inside use the specified preset.

<%!-- Scope a section to the rose preset --%>
<.theme_provider theme={:rose}>
  <.button>Rose button</.button>
  <.badge>Rose badge</.badge>
</.theme_provider>

<%!-- Preview multiple themes --%>
<div class="grid grid-cols-4 gap-4">
  <.theme_provider :for={preset <- [:blue, :rose, :green, :violet]} theme={preset}>
    <.card>
      <.card_content class="pt-4">
        <.button class="w-full"><%= preset %></.button>
      </.card_content>
    </.card>
  </.theme_provider>
</div>

kbd

Semantic <kbd> element for displaying keyboard shortcuts.

<p>Press <.kbd>Ctrl</.kbd> + <.kbd>K</.kbd> to open the command palette.</p>
<p>Use <.kbd>⌘</.kbd> + <.kbd>Z</.kbd> to undo.</p>
<p>Navigate with <.kbd>↑</.kbd> <.kbd>↓</.kbd> arrow keys.</p>

direction

LTR/RTL wrapper for multilingual layouts. Sets the HTML dir attribute on its container.

<.direction dir="ltr">
  <p>Left-to-right content (English, French, etc.)</p>
</.direction>

<.direction dir="rtl">
  <p>محتوى من اليمين إلى اليسار</p>
</.direction>

Back to README