Sutra UI Usage Rules for LLMs
View SourceThis 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.mdfor Sutra's design philosophy and reference routing. - Use
skill/patterns.mdfor API selection and composition rules. - Use
skill/recipes.mdfor common full-interface compositions. - Use
cheatsheets/components.cheatmdandcheatsheets/forms.cheatmdfor compact examples. - Inspect
lib/sutra_ui/*.exbefore 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.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 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:
| Attribute | Type | Notes |
|---|---|---|
class | :string or :any | Prefer :string; use :any when list-style class values are intentional |
id | :string | Required when hook behavior needs a stable DOM target; optional only for progressive enhancements |
disabled | :boolean | Boolean attributes |
value | :string or :any | Depends on component needs |
errors | :list or :map | Lists 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)
end7. 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
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" runtime>
{
mounted() {
// Initialize component
},
updated() {
// Handle LiveView updates
},
destroyed() {
// Cleanup
}
}
</script>
"""
end
endMost 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, destructivebadge/1- Status badgesspinner/1- Loading indicatorskbd/1- Keyboard shortcut display
Form Controls
label/1- Form labelsinput/1- Unified form fields with label, description, native select, and error supporttextarea/1- Multi-line text inputcheckbox/1- Checkbox inputswitch/1- Toggle switchradio_group/1,radio/1- Radio button groupssimple_form/1- Styled plain form shell; use Phoenix<.form class="form">for changeset-backed LiveView formsselect/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 withallow_upload/3
Layout & Data Display
card/1- Card container with header/content/footer slotsheader/1- Page header with title/subtitle/actionstable/1- Raw styled table wrapperdata_table/1- Data table with column definitionsskeleton/1- Loading placeholderempty/1- Empty state displayalert/1- Alert/callout messagesflash/1- Phoenix flash messagesprogress/1- Progress barstepper/1- Multi-step progress indicatorstepper_wizard/1- Multi-step wizard shell for large forms and flowstimeline/1- Chronological event list with item slotstree_view/1,tree_item/1- Hierarchical tree navigation (idenables keyboard hook)
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)context_menu/1- Right-click menu (JS hook)toast_container/1,toast/1- Toast notifications (JS hook)
Advanced UI
avatar/1- User avatars with fallbacktooltip/1- Hover and focus tooltip (JS hook)hover_card/1- Rich hover preview (JS hook)dialog/1- Modal dialogspopover/1- Click-triggered popupscommand/1- Command palette with search (JS hook)carousel/1- CSS scroll-snap carouselcalendar/1- Monthly calendar gridmarquee/1- CSS-only scrolling content bannerseparator/1- Visual or semantic divider
AI Primitives
response/1- Text responses with optional reveal styles, or streamed Markdownactivity/1- Safe user-facing agent progress with slot-owned row content
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 message
Navigation
drawer/1- Collapsible drawer navigationtab_nav/1- Responsive server-side routed tab navigationtheme_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
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
<%!-- 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
endData 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")}
endTabs 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
})}
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"}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>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:
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}> <.select_option value="admin" label="Administrator" /> </.select>Check phx-change - Form needs
phx-changefor 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:
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)} endOr 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">Check on_cancel handler - Close button only renders when
on_cancelis set
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 - 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);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 SutraUI.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 - Remove manual hook registrations from old
hooks.jsfiles - Keep generated
phoenix-colocated/sutra_uiimports when using extracted hooks
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