Sutra UI Usage Rules for LLMs
View SourceThis document provides guidelines for AI assistants when working with the Sutra UI component library.
Overview
Sutra UI is a pure Phoenix LiveView UI component library with no external dependencies. Components use colocated JavaScript hooks where interactivity is needed.
Requirements:
- Phoenix 1.8+ (for colocated hooks)
- Phoenix LiveView 1.1+ (ColocatedHook support)
- Tailwind CSS v4 (CSS-first configuration)
Core Principles
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.csswhen 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 generate DOM references must have a required id attribute:
# 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:
| Attribute | Type | Notes |
|---|---|---|
class | :string | Always :string, merged internally with list pattern |
id | :string | Required for hook-based components |
disabled | :boolean | Boolean attributes |
value | :string or :any | Depends on component needs |
errors | :list | List of error messages |
5. 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)
endComponent 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
endHook-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">
export default {
mounted() {
// Initialize component
},
updated() {
// Handle LiveView updates
},
destroyed() {
// Cleanup
}
}
</script>
"""
end
endAvailable Components
Foundation
button/1- Buttons with variants: primary, secondary, outline, ghost, link, destructivebadge/1- Status badgesspinner/1- Loading indicatorskbd/1- Keyboard shortcut display
Form Controls
label/1- Form labelsinput/1- Text inputs (text, email, password, number, etc.)textarea/1- Multi-line text inputcheckbox/1- Checkbox inputswitch/1- Toggle switchradio_group/1,radio/1- Radio button groupsfield/1,fieldset/1- Field containers with label/description/errorselect/1- Custom select dropdown (JS hook)slider/1- Range slider (JS hook)range_slider/1- Dual-handle range slider (JS hook)live_select/1- Async searchable select (JS hook)
Layout & Data Display
card/1- Card container with header/content/footer slotsheader/1- Page header with title/subtitle/actionstable/1- Data table with column definitionsskeleton/1- Loading placeholderempty/1- Empty state displayalert/1- Alert/callout messagesprogress/1- Progress bar
Navigation & Interactive
breadcrumb/1- Breadcrumb navigationpagination/1- Page navigationaccordion/1- Collapsible content sectionstabs/1- Tab panels (JS hook)dropdown_menu/1- Dropdown menu (JS hook)toast/1,toaster/1- Toast notifications (JS hook)
Advanced UI
avatar/1- User avatars with fallbacktooltip/1- CSS-only hover tooltipsdialog/1- Modal dialogspopover/1- Click-triggered popupscommand/1- Command palette with search (JS hook)carousel/1- CSS scroll-snap carousel
Layout Helpers
filter_bar/1- Filter bar for index pagesinput_group/1- Input with prefix/suffixitem/1- Versatile list itemloading_state/1- Loading indicator with messagesimple_form/1- Form wrapper with auto-styling
Navigation
nav_pills/1- Responsive navigation pillssidebar/1- Collapsible sidebar navigationtab_nav/1- Server-side routed tab navigationtheme_switcher/1- Light/dark theme toggle
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/ and use ComponentCase:
defmodule SutraUI.ComponentNameTest do
use 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
endCommon Mistakes to Avoid
- Missing required
idon hook-based components - Inline Tailwind instead of CSS classes
- Auto-generating IDs instead of requiring them
- Missing accessibility attributes (ARIA, roles, keyboard support)
- Not updating moduledoc examples when changing required attrs
- Forgetting to add new components to
lib/sutra_ui.eximports
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 helpersAdding a New Component
- Create
lib/sutra_ui/component_name.exwith moduledoc and examples - Add CSS classes to
priv/static/sutra_ui.css - Add import to
lib/sutra_ui.exin__using__macro - Update component list in
lib/sutra_ui.exmoduledoc - Create tests in
test/sutra_ui/component_name_test.exs - Update this file if the component introduces new patterns
Common Recipes
Modal Dialog with Form
<.dialog id="edit-user-dialog">
<:title>Edit User</:title>
<:description>Update user information below.</:description>
<.simple_form for={@form} phx-submit="save_user">
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} label="Email" type="email" />
<:actions>
<.button variant="outline" phx-click={SutraUI.Dialog.hide_dialog("edit-user-dialog")}>
Cancel
</.button>
<.button type="submit">Save Changes</.button>
</:actions>
</.simple_form>
</.dialog>
<.button phx-click={SutraUI.Dialog.show_dialog("edit-user-dialog")}>
Edit User
</.button>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>
<.button variant="ghost" size="icon" aria-label="Actions">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</.button>
</:trigger>
<:content>
<.dropdown_menu_item navigate={~p"/users/#{user.id}"}>View</.dropdown_menu_item>
<.dropdown_menu_item navigate={~p"/users/#{user.id}/edit"}>Edit</.dropdown_menu_item>
<.dropdown_menu_separator />
<.dropdown_menu_item variant="destructive" phx-click="delete_user" phx-value-id={user.id}>
Delete
</.dropdown_menu_item>
</:content>
</.dropdown_menu>
</:action>
</.data_table>
<.pagination
page={@page}
total_pages={@total_pages}
path={~p"/users"}
/>Form with Validation Errors
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.field field={@form[:name]} label="Name" required>
<.input field={@form[:name]} />
</.field>
<.field field={@form[:email]} label="Email" required>
<.input field={@form[:email]} type="email" />
</.field>
<.field field={@form[:role]} label="Role">
<.select id="role-select" name={@form[:role].name} value={@form[:role].value}>
<.select_option value="admin" label="Administrator" />
<.select_option value="user" label="Standard User" />
<.select_option value="viewer" label="Viewer" />
</.select>
</.field>
<:actions>
<.button type="submit" loading={@saving}>Save</.button>
</:actions>
</.simple_form>Confirmation Dialog Pattern
# In your LiveView
def handle_event("confirm_delete", %{"id" => id}, socket) do
socket = assign(socket, :delete_target_id, id)
{:noreply, push_event(socket, "js-exec", %{to: "#confirm-delete-dialog", attr: "phx-show-dialog"})}
end
def handle_event("delete_confirmed", _, socket) do
MyApp.delete_item(socket.assigns.delete_target_id)
{:noreply,
socket
|> put_flash(:info, "Item deleted")
|> push_navigate(to: ~p"/items")}
end<.dialog id="confirm-delete-dialog">
<:title>Delete Item</:title>
<:description>This action cannot be undone. Are you sure?</:description>
<:footer>
<.button variant="outline" phx-click={SutraUI.Dialog.hide_dialog("confirm-delete-dialog")}>
Cancel
</.button>
<.button variant="destructive" phx-click="delete_confirmed">
Delete
</.button>
</:footer>
</.dialog>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>
<:title>Overview</:title>
</:header>
<:content>
<p>Overview content here...</p>
</:content>
</.card>
</:panel>
<:panel value="activity">
<.card>
<:header>
<:title>Recent Activity</:title>
</:header>
<:content>
<ul>
<.item :for={activity <- @activities}>
{activity.description}
</.item>
</ul>
</:content>
</.card>
</:panel>
<:panel value="settings">
<.simple_form for={@settings_form} phx-submit="save_settings">
<.switch field={@settings_form[:notifications]} label="Enable notifications" />
<.switch field={@settings_form[:dark_mode]} label="Dark mode" />
</.simple_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
})}
endTroubleshooting
Component hooks not working
Symptoms: Hook-based components (Select, Dialog, Tabs, etc.) don't respond to interactions.
Solutions:
Check Phoenix version - Colocated hooks require Phoenix 1.8+:
# mix.exs {:phoenix, "~> 1.8"}Verify hook import - Ensure colocated hooks are imported in
app.js:// app.js import { hooks as sutraUiHooks } from "phoenix-colocated/sutra_ui"; let liveSocket = new LiveSocket("/live", Socket, { hooks: { ...sutraUiHooks } })Check component ID - Hook-based components require unique IDs:
<%# Wrong - missing ID %> <.select name="country"> <%# Correct %> <.select id="country-select" name="country">
CSS styles not applied
Symptoms: Components render but look unstyled or broken.
Solutions:
Check Tailwind v4 setup - Verify
@sourcedirective inapp.css:@import "tailwindcss"; @source "../../deps/sutra_ui/lib"; @import "../../deps/sutra_ui/priv/static/sutra_ui.css";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 */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:
Check name attribute - Ensure
namematches the form field:<.select id="role-select" name={@form[:role].name} value={@form[:role].value}>Check phx-change - Form needs
phx-changefor live validation:<.simple_form for={@form} phx-change="validate" phx-submit="save">
Dialog not opening/closing
Symptoms: show_dialog/hide_dialog functions don't work.
Solutions:
Use correct module path:
<%# Wrong %> <.button phx-click={show_dialog("my-dialog")}> <%# Correct %> <.button phx-click={SutraUI.Dialog.show_dialog("my-dialog")}>Check dialog ID - Must match exactly:
<.dialog id="confirm-dialog"> <.button phx-click={SutraUI.Dialog.show_dialog("confirm-dialog")}>
LiveView disconnects on component interaction
Symptoms: Page refreshes or socket disconnects when clicking components.
Solutions:
Check event handlers - Ensure
phx-clickhandlers exist in LiveView:def handle_event("my_action", params, socket) do {:noreply, socket} endPrevent default on links - Use
phx-clickinstead ofonclickfor LiveView events.
Theme variables not working
Symptoms: Custom CSS variables are ignored.
Solutions:
Check variable syntax - Use OKLCH format:
/* Wrong */ --primary: #3b82f6; /* Correct */ --primary: oklch(0.623 0.214 259.815);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:
Import correctly - Test files need explicit imports:
defmodule SutraUI.ButtonTest do use ComponentCase, async: true import SutraUI.ButtonUse render_component - Not
render:html = render_component(&button/1, variant: "primary")
Migration Notes
From Phoenix 1.7 to 1.8+
- Update dependencies in
mix.exs - Update
app.jsto import colocated hooks fromphoenix-colocated/sutra_ui - Remove any manual hook registrations from old
hooks.jsfiles - colocated hooks are now extracted automatically
From Tailwind v3 to v4
- Replace
tailwind.config.jswith@sourcedirectives in CSS - Update color values from HSL to OKLCH if customizing theme
- Update any custom CSS using Tailwind's
@applywith new syntax