Sutra UI Usage Rules for LLMs

View Source

This document provides guidelines for AI assistants when working with the Sutra UI component library.

For application-level composition, start with SKILL.md, then use cheatsheets/components.cheatmd for component examples and cheatsheets/forms.cheatmd for form patterns. Use this file when editing Sutra UI internals, adding components, or reviewing implementation quality.

Overview

Sutra UI is a pure Phoenix LiveView UI component library with no external JavaScript/npm dependencies. Components use colocated JavaScript hooks where interactivity is needed; most are runtime hooks, with extracted exceptions documented in the hook guide.

Requirements:

  • Phoenix 1.8+ (for colocated hooks)
  • Phoenix LiveView 1.2+
  • Tailwind CSS v4 (CSS-first configuration)

Core Principles

Reference Order

  • Start with SKILL.md for Sutra's design philosophy and reference routing.
  • Use skill/patterns.md for API selection and composition rules.
  • Use skill/recipes.md for common full-interface compositions.
  • Use cheatsheets/components.cheatmd and cheatsheets/forms.cheatmd for compact examples.
  • Inspect lib/sutra_ui/*.ex before relying on exact attrs, slots, events, or helper function names.

1. CSS-First Styling

All component styling is defined in priv/static/sutra_ui.css. When modifying components:

  • DO: Use CSS classes from sutra_ui.css
  • DO: Add new CSS classes to sutra_ui.css when needed
  • DON'T: Add inline Tailwind classes directly in component templates
  • DON'T: Use helper functions to generate Tailwind class strings

Pattern for class attributes:

# Good - uses CSS class with optional user override
class={["component-class", @class]}

# Avoid - inline Tailwind
class={["flex items-center gap-2", @class]}

2. Required ID Attributes

Components that require JavaScript hooks or stable DOM references expose an id attribute. Hook-only components generally require it; progressive-enhancement components may enable extra behavior only when an id is provided:

# Good - explicit required ID
attr(:id, :string, required: true, doc: "Unique identifier for the component")

# Avoid - auto-generated IDs
attr(:id, :string, default: fn -> "component-#{System.unique_integer()}" end)

Rationale:

  • Predictable IDs aid debugging
  • Prevents LiveView diffing issues on reconnect
  • User maintains control over their DOM

3. Event Naming Convention

Custom JavaScript events use the sutra-ui: namespace:

// Good
this.el.dispatchEvent(new CustomEvent('sutra-ui:component-action', { detail: {...} }))

// Avoid
this.el.dispatchEvent(new CustomEvent('component-action', { detail: {...} }))

4. Attribute Types

Use appropriate attribute types:

AttributeTypeNotes
class:string or :anyPrefer :string; use :any when list-style class values are intentional
id:stringRequired when hook behavior needs a stable DOM target; optional only for progressive enhancements
disabled:booleanBoolean attributes
value:string or :anyDepends on component needs
errors:list or :mapLists for field messages; maps for keyed workflows like steppers

5. Global Attributes

Use :global for attributes passed through to rendered HTML. Phoenix already accepts all phx-*, data-*, and aria-* attributes on :global; the include: option only adds extra allowed attributes.

attr(:rest, :global, include: ~w(form type name value), doc: "Additional HTML attributes")
<.button phx-click="select_page" phx-value-page="2" data-testid="next-page">
  Next
</.button>

Do not treat examples like phx-value-id as a whitelist. Any phx-value-* payload supported by Phoenix LiveView can be passed through component globals.

6. Slot Conventions

Slots should follow consistent patterns:

# Simple content slot
slot(:inner_block, doc: "Main content")

# Named slot with attributes
slot :item, doc: "List items" do
  attr(:value, :string, required: true)
  attr(:disabled, :boolean)
end

7. Icons

Sutra UI does not export a general icon component or bundled icon set. Library components use inline SVG only where they own the icon.

  • DO use a host application's existing icon helper if it has one.
  • DO use inline SVG for local examples when no app helper exists.
  • DON'T invent SutraUI.Icon, icon/1, icon_name, or a new icon package.
  • DON'T copy the demo app's <.icon name="lucide-..."> helper into generic library guidance unless the target app provides it.

Icon-only buttons and controls must have an accessible name such as aria-label.

Component Patterns

Basic Component Structure

defmodule SutraUI.ComponentName do
  @moduledoc """
  Brief description of the component.

  ## Examples

      <.component_name id="my-component" required_attr="value">
        Content here
      </.component_name>

  ## Accessibility

  - List ARIA attributes used
  - Keyboard navigation support
  - Screen reader considerations
  """

  use Phoenix.Component

  @doc """
  Renders the component.
  """
  attr(:id, :string, required: true, doc: "Unique identifier")
  attr(:class, :string, default: nil, doc: "Additional CSS classes")
  attr(:rest, :global, doc: "Additional HTML attributes")

  slot(:inner_block, required: true, doc: "Main content")

  def component_name(assigns) do
    ~H"""
    <div id={@id} class={["component-class", @class]} {@rest}>
      {render_slot(@inner_block)}
    </div>
    """
  end
end

Hook-Based Component Structure

defmodule SutraUI.InteractiveComponent do
  use Phoenix.Component
  alias Phoenix.LiveView.ColocatedHook

  attr(:id, :string, required: true, doc: "Required for JavaScript hook")
  # ... other attrs

  def interactive_component(assigns) do
    ~H"""
    <div id={@id} class="component-class" phx-hook=".ComponentHook" data-option={@option}>
      {render_slot(@inner_block)}
    </div>

    <script :type={ColocatedHook} name=".ComponentHook" runtime>
      {
        mounted() {
          // Initialize component
        },
        updated() {
          // Handle LiveView updates
        },
        destroyed() {
          // Cleanup
        }
      }
    </script>
    """
  end
end

Most Sutra UI hooks are runtime colocated hooks. Extracted hook exceptions must document the generated phoenix-colocated/sutra_ui LiveSocket import.

Available Components

Foundation

  • button/1 - Buttons with variants: primary, secondary, outline, ghost, link, destructive
  • badge/1 - Status badges
  • spinner/1 - Loading indicators
  • kbd/1 - Keyboard shortcut display

Form Controls

  • label/1 - Form labels
  • input/1 - Unified form fields with label, description, native select, and error support
  • textarea/1 - Multi-line text input
  • checkbox/1 - Checkbox input
  • switch/1 - Toggle switch
  • radio_group/1, radio/1 - Radio button groups
  • simple_form/1 - Styled plain form shell; use Phoenix <.form class="form"> for changeset-backed LiveView forms
  • select/1 - Custom select dropdown (JS hook)
  • slider/1 - Range slider (JS hook)
  • range_slider/1 - Dual-handle range slider (JS hook)
  • SutraUI.LiveSelect - LiveComponent async searchable select; render with <.live_component module={SutraUI.LiveSelect} ... />
  • input_otp/1 - One-time password input (JS hook)
  • file_upload/1 - LiveView upload dropzone configured with allow_upload/3

Layout & Data Display

  • card/1 - Card container with header/content/footer slots
  • header/1 - Page header with title/subtitle/actions
  • table/1 - Raw styled table wrapper
  • data_table/1 - Data table with column definitions
  • skeleton/1 - Loading placeholder
  • empty/1 - Empty state display
  • alert/1 - Alert/callout messages
  • flash/1 - Phoenix flash messages
  • progress/1 - Progress bar
  • stepper/1 - Multi-step progress indicator
  • stepper_wizard/1 - Multi-step wizard shell for large forms and flows
  • timeline/1 - Chronological event list with item slots
  • tree_view/1, tree_item/1 - Hierarchical tree navigation (id enables keyboard hook)
  • breadcrumb/1 - Breadcrumb navigation
  • pagination/1 - Page navigation
  • accordion/1 - Collapsible content sections
  • tabs/1 - Tab panels (JS hook)
  • dropdown_menu/1 - Dropdown menu (JS hook)
  • context_menu/1 - Right-click menu (JS hook)
  • toast_container/1, toast/1 - Toast notifications (JS hook)

Advanced UI

  • avatar/1 - User avatars with fallback
  • tooltip/1 - Hover and focus tooltip (JS hook)
  • hover_card/1 - Rich hover preview (JS hook)
  • dialog/1 - Modal dialogs
  • popover/1 - Click-triggered popups
  • command/1 - Command palette with search (JS hook)
  • carousel/1 - CSS scroll-snap carousel
  • calendar/1 - Monthly calendar grid
  • marquee/1 - CSS-only scrolling content banner
  • separator/1 - Visual or semantic divider

AI Primitives

  • response/1 - Text responses with optional reveal styles, or streamed Markdown
  • activity/1 - Safe user-facing agent progress with slot-owned row content

Layout Helpers

  • filter_bar/1 - Filter bar for index pages
  • input_group/1 - Input with prefix/suffix
  • item/1 - Versatile list item
  • loading_state/1 - Loading indicator with message
  • drawer/1 - Collapsible drawer navigation
  • tab_nav/1 - Responsive server-side routed tab navigation
  • theme_switcher/1 - Light/dark theme event button

CSS Class Naming

CSS classes in sutra_ui.css follow these conventions:

/* Component base */
.component-name { }

/* Variants */
.component-name-variant { }

/* States */
.component-name-disabled { }
.component-name-active { }

/* Sub-elements */
.component-name-header { }
.component-name-content { }
.component-name-footer { }

Testing Components

Tests are in test/sutra_ui/. Most existing tests use ExUnit.Case with Phoenix.LiveViewTest.rendered_to_string/1; SutraUI.ComponentCase is available when its helpers are useful:

defmodule SutraUI.ComponentNameTest do
  use SutraUI.ComponentCase, async: true

  import SutraUI.ComponentName

  describe "component_name/1" do
    test "renders with required attributes" do
      html = render_component(&component_name/1, id: "test", required: "value")
      assert html =~ ~s(id="test")
    end

    test "applies custom class" do
      html = render_component(&component_name/1, id: "test", class: "custom")
      assert html =~ "custom"
    end
  end
end

Common Mistakes to Avoid

  1. Missing required id on hook-based components
  2. Inline Tailwind instead of CSS classes
  3. Auto-generating IDs instead of requiring them
  4. Missing accessibility attributes (ARIA, roles, keyboard support)
  5. Not updating moduledoc examples when changing required attrs
  6. Forgetting to add new components to lib/sutra_ui.ex imports

File Structure

lib/
  sutra_ui/
    component_name.ex    # Component module
  sutra_ui.ex            # Main module with imports
priv/
  static/
    sutra_ui.css         # All component styles
test/
  sutra_ui/
    component_name_test.exs
  support/
    component_case.ex    # Test helpers

Adding a New Component

  1. Create lib/sutra_ui/component_name.ex with moduledoc and examples
  2. Add CSS classes to priv/static/sutra_ui.css
  3. Add import to lib/sutra_ui.ex in __using__ macro
  4. Update component list in lib/sutra_ui.ex moduledoc
  5. Create tests in test/sutra_ui/component_name_test.exs
  6. Update this file if the component introduces new patterns

Common Recipes

<%!-- Server-controlled dialog (recommended) --%>
<.dialog id="edit-user-dialog" show={@show_edit_dialog} on_cancel="close_edit_dialog">
  <:title>Edit User</:title>
  <:description>Update user information below.</:description>

  <.form for={@form} class="form" phx-submit="save_user">
    <.input field={@form[:name]} label="Name" />
    <.input field={@form[:email]} label="Email" type="email" />

    <div class="flex justify-end gap-2">
      <.button variant="outline" phx-click="close_edit_dialog">
        Cancel
      </.button>
      <.button type="submit">Save Changes</.button>
    </div>
  </.form>
</.dialog>

<.button phx-click="open_edit_dialog">
  Edit User
</.button>
# In your LiveView
def handle_event("open_edit_dialog", _, socket) do
  {:noreply, assign(socket, show_edit_dialog: true)}
end

def handle_event("close_edit_dialog", _, socket) do
  {:noreply, assign(socket, show_edit_dialog: false)}
end

def handle_event("save_user", params, socket) do
  case save_user(params) do
    {:ok, _user} ->
      {:noreply, assign(socket, show_edit_dialog: false)}
    {:error, changeset} ->
      {:noreply, assign(socket, form: to_form(changeset))}
  end
end

Data Table with Pagination

<.data_table rows={@users}>
  <:col :let={user} label="Name">{user.name}</:col>
  <:col :let={user} label="Email">{user.email}</:col>
  <:col :let={user} label="Status">
    <.badge variant={status_variant(user.status)}>{user.status}</.badge>
  </:col>
  <:action :let={user}>
    <.dropdown_menu id={"user-actions-#{user.id}"}>
      <:trigger>Actions</:trigger>
      <.dropdown_item><.link navigate={~p"/users/#{user.id}"}>View</.link></.dropdown_item>
      <.dropdown_item><.link navigate={~p"/users/#{user.id}/edit"}>Edit</.link></.dropdown_item>
      <.dropdown_separator />
      <.dropdown_item variant="destructive" phx-click="delete_user" phx-value-id={user.id}>
        Delete
      </.dropdown_item>
    </.dropdown_menu>
  </:action>
</.data_table>

<.pagination
  page={@page}
  total_pages={@total_pages}
  on_page_change="page_changed"
/>

Form with Validation Errors

<.form for={@form} class="form" phx-change="validate" phx-submit="save">
  <.input field={@form[:name]} label="Name" required />

  <.input field={@form[:email]} type="email" label="Email" required />

  <.input
    field={@form[:role]}
    type="select"
    label="Role"
    options={[{"Administrator", "admin"}, {"Standard User", "user"}, {"Viewer", "viewer"}]}
  />

  <.button type="submit" loading={@saving}>Save</.button>
</.form>

Confirmation Dialog Pattern

<.dialog
  id="confirm-delete-dialog"
  show={@show_delete_dialog}
  on_cancel="cancel_delete"
>
  <:title>Delete Item</:title>
  <:description>This action cannot be undone. Are you sure?</:description>
  <:footer>
    <.button variant="outline" phx-click="cancel_delete">
      Cancel
    </.button>
    <.button variant="destructive" phx-click="delete_confirmed">
      Delete
    </.button>
  </:footer>
</.dialog>
# In your LiveView
def handle_event("confirm_delete", %{"id" => id}, socket) do
  {:noreply, assign(socket, delete_target_id: id, show_delete_dialog: true)}
end

def handle_event("cancel_delete", _, socket) do
  {:noreply, assign(socket, show_delete_dialog: false)}
end

def handle_event("delete_confirmed", _, socket) do
  MyApp.delete_item(socket.assigns.delete_target_id)

  {:noreply,
   socket
   |> assign(show_delete_dialog: false)
   |> put_flash(:info, "Item deleted")
   |> push_navigate(to: ~p"/items")}
end

Tabs with Dynamic Content

<.tabs id="content-tabs" default_value="overview">
  <:tab value="overview">Overview</:tab>
  <:tab value="activity">Activity</:tab>
  <:tab value="settings">Settings</:tab>
  
  <:panel value="overview">
    <.card>
      <:header>
        <h2>Overview</h2>
      </:header>
      <:content>
        <p>Overview content here...</p>
      </:content>
    </.card>
  </:panel>
  
  <:panel value="activity">
    <.card>
      <:header>
        <h2>Recent Activity</h2>
      </:header>
      <:content>
        <ul>
          <.item :for={activity <- @activities}>
            <:title>{activity.description}</:title>
          </.item>
        </ul>
      </:content>
    </.card>
  </:panel>
  
  <:panel value="settings">
    <.form for={@settings_form} class="form" phx-submit="save_settings">
      <.input field={@settings_form[:notifications]} type="switch" label="Enable notifications" />
      <.input field={@settings_form[:dark_mode]} type="switch" label="Dark mode" />
    </.form>
  </:panel>
</.tabs>

Toast Notifications from LiveView

# Show toast on successful action
def handle_event("save", params, socket) do
  case save_data(params) do
    {:ok, _record} ->
      {:noreply,
       socket
       |> put_flash(:info, "Changes saved successfully")}
    
    {:error, changeset} ->
      {:noreply, assign(socket, :form, to_form(changeset))}
  end
end

# Or use push_event for more control
def handle_event("export_complete", _, socket) do
  {:noreply,
   push_event(socket, "toast", %{
     variant: "success",
     title: "Export Complete",
     description: "Your data has been exported to CSV.",
     duration: 5000
   })}
end

Troubleshooting

Component hooks not working

Symptoms: Hook-based components (Select, Dialog, Tabs, etc.) don't respond to interactions.

Solutions:

  1. Check Phoenix version - Colocated hooks require Phoenix 1.8+:

    # mix.exs
    {:phoenix, "~> 1.8"}
  2. Check component ID - many hook-based components require unique IDs:

    <%# Wrong - missing ID %>
    <.select name="country">
      <.select_option value="us" label="United States" />
    </.select>
    
    <%# Correct %>
    <.select id="country-select" name="country">
      <.select_option value="us" label="United States" />
    </.select>
  3. Check rendered hook markup - Runtime hooks are emitted with the component. If the hook script is missing, recompile the app:

    mix compile --force
    

CSS styles not applied

Symptoms: Components render but look unstyled or broken.

Solutions:

  1. Check Tailwind v4 setup - Verify @source directive in app.css:

    @import "tailwindcss";
    @source "../../deps/sutra_ui/lib";
    @import "../../deps/sutra_ui/priv/static/sutra_ui.css";
  2. Check import order - Sutra UI CSS must come after Tailwind:

    @import "tailwindcss";
    @import "../../deps/sutra_ui/priv/static/sutra_ui.css";
    /* Your overrides come last */
  3. Clear cache - After changing CSS config:

    mix assets.clean
    mix phx.server
    

Form values not updating

Symptoms: Select or other form controls don't update the form value.

Solutions:

  1. Check name attribute - Ensure name matches the form field:

    <.select id="role-select" name={@form[:role].name} value={@form[:role].value}>
      <.select_option value="admin" label="Administrator" />
    </.select>
  2. Check phx-change - Form needs phx-change for live validation:

    <.form for={@form} class="form" phx-change="validate" phx-submit="save">

Dialog not opening/closing

Symptoms: Dialog doesn't show or hide properly.

Solutions:

  1. Use server-controlled pattern (recommended):

    <%!-- Control via show attribute and on_cancel event --%>
    <.dialog id="my-dialog" show={@show_dialog} on_cancel="close_dialog">
      ...
    </.dialog>
    
    <.button phx-click="open_dialog">Open</.button>
    def handle_event("open_dialog", _, socket) do
      {:noreply, assign(socket, show_dialog: true)}
    end
    
    def handle_event("close_dialog", _, socket) do
      {:noreply, assign(socket, show_dialog: false)}
    end
  2. Or use JS commands - If using show_dialog/hide_dialog:

    <%!-- Use correct module path --%>
    <.button phx-click={SutraUI.Dialog.show_dialog("my-dialog")}>
    
    <%!-- ID must match exactly --%>
    <.dialog id="my-dialog" on_cancel="close">
  3. Check on_cancel handler - Close button only renders when on_cancel is set

LiveView disconnects on component interaction

Symptoms: Page refreshes or socket disconnects when clicking components.

Solutions:

  1. Check event handlers - Ensure phx-click handlers exist in LiveView:

    def handle_event("my_action", params, socket) do
      {:noreply, socket}
    end
  2. Prevent default on links - Use phx-click instead of onclick for LiveView events.

Theme variables not working

Symptoms: Custom CSS variables are ignored.

Solutions:

  1. Check variable syntax - Sutra UI defaults use OKLCH; other valid CSS color formats also work if they give sufficient contrast:

    /* Valid, but less consistent with the default palette */
    --primary: #3b82f6;
    
    /* Preferred for Sutra UI themes */
    --primary: oklch(0.623 0.214 259.815);
  2. Check override order - Custom variables must come after Sutra UI CSS:

    @import "../../deps/sutra_ui/priv/static/sutra_ui.css";
    
    :root {
      --primary: oklch(0.65 0.20 145); /* This overrides */
    }

Component tests failing

Symptoms: Tests can't find components or hooks.

Solutions:

  1. Import correctly - Test files need explicit imports:

    defmodule SutraUI.ButtonTest do
      use SutraUI.ComponentCase, async: true
      import SutraUI.Button
  2. Use render_component - Not render:

    html = render_component(&button/1, variant: "primary")

Migration Notes

From Phoenix 1.7 to 1.8+

  1. Update dependencies in mix.exs
  2. Remove manual hook registrations from old hooks.js files
  3. Keep generated phoenix-colocated/sutra_ui imports when using extracted hooks

From Tailwind v3 to v4

  1. Replace tailwind.config.js with @source directives in CSS
  2. Update color values from HSL to OKLCH if customizing theme
  3. Update any custom CSS using Tailwind's @apply with new syntax