Theme Customization Guide
View SourceVersion: 0.2.2
Audience: Developers who want to customize form rendering with their own CSS framework or design system
Table of Contents
- Overview
- How Themes Work
- Creating a Custom Theme
- Theme Callbacks Reference
- Common Customization Patterns
- Using Your Custom Theme
- Theme Options
- Troubleshooting
Overview
AshFormBuilder's theme system allows you to completely customize how form fields are rendered without modifying your form logic. You can:
- ✅ Use a different CSS framework (Tailwind, Bootstrap, Bulma, etc.)
- ✅ Customize individual field types (only change checkboxes, keep everything else)
- ✅ Add custom validation error styling
- ✅ Integrate with your design system components
- ✅ Support RTL languages or accessibility requirements
Built-in Themes:
AshFormBuilder.Themes.Default- Semantic HTML with minimal CSS classesAshFormBuilder.Theme.MishkaTheme- MishkaChelekom component integration (requiresmishka_chelekomdependency)
How Themes Work
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Your LiveView │
│ <.live_component module={AshFormBuilder.FormComponent} /> │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AshFormBuilder.FormComponent │
│ - Reads theme from Application.get_env/2 │
│ - Passes theme to FormRenderer │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AshFormBuilder.FormRenderer │
│ - Iterates form fields │
│ - Calls theme.render_field/2 for each field │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Your Custom Theme Module │
│ - Pattern matches on field.type │
│ - Returns Phoenix.LiveView.Rendered (HEEx template) │
└─────────────────────────────────────────────────────────────┘Configuration Flow
- Configure theme in
config/config.exs - FormComponent reads the theme at runtime
- FormRenderer delegates field rendering to your theme
- Your theme renders HTML with your CSS classes
Creating a Custom Theme
Step 1: Create the Module
Create a new module in your application (e.g., lib/my_app_web/form_builder/tailwind_theme.ex):
defmodule MyAppWeb.FormBuilder.TailwindTheme do
@moduledoc """
Custom Tailwind CSS theme for AshFormBuilder.
Renders form fields with Tailwind CSS utility classes.
"""
@behaviour AshFormBuilder.Theme
use Phoenix.ComponentStep 2: Implement Required Callbacks
Your theme MUST implement render_field/2:
@impl AshFormBuilder.Theme
def render_field(assigns, opts) do
# Pattern match on field type and render accordingly
case assigns.field.type do
:text_input -> render_text_input(assigns)
:textarea -> render_textarea(assigns)
:select -> render_select(assigns)
:checkbox -> render_checkbox(assigns)
:number -> render_number(assigns)
:email -> render_email(assigns)
:password -> render_password(assigns)
:date -> render_date(assigns)
:datetime -> render_datetime(assigns)
:url -> render_url(assigns)
:tel -> render_tel(assigns)
:hidden -> render_hidden(assigns)
:multiselect_combobox -> render_combobox(assigns)
:file_upload -> render_file_upload(assigns)
_ -> render_text_input(assigns)
end
endStep 3: Implement Field Rendering Functions
Create private functions for each field type:
defp render_text_input(assigns) do
~H"""
<div class={["mb-4", @field.wrapper_class]}>
<label
:if={@field.label}
for={Phoenix.HTML.Form.input_id(@form, @field.name)}
class="block text-sm font-medium text-gray-700 mb-1"
>
{@field.label}
<span :if={@field.required} class="text-red-500 ml-1">*</span>
</label>
<input
type="text"
id={Phoenix.HTML.Form.input_id(@form, @field.name)}
name={Phoenix.HTML.Form.input_name(@form, @field.name)}
value={Phoenix.HTML.Form.input_value(@form, @field.name)}
placeholder={@field.placeholder}
class={"w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 #{@field.class}"}
/>
<.field_hint hint={@field.hint} />
<.field_errors form={@form} field={@field.name} />
</div>
"""
end
defp render_checkbox(assigns) do
~H"""
<div class={["mb-4", @field.wrapper_class]}>
<div class="flex items-center">
<input
type="hidden"
name={Phoenix.HTML.Form.input_name(@form, @field.name)}
value="false"
/>
<input
type="checkbox"
id={Phoenix.HTML.Form.input_id(@form, @field.name)}
name={Phoenix.HTML.Form.input_name(@form, @field.name)}
value="true"
checked={Phoenix.HTML.Form.input_value(@form, @field.name) in [true, "true"]}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label
:if={@field.label}
for={Phoenix.HTML.Form.input_id(@form, @field.name)}
class="ml-2 block text-sm text-gray-900"
>
{@field.label}
<span :if={@field.required} class="text-red-500 ml-1">*</span>
</label>
</div>
<.field_errors form={@form} field={@field.name} />
</div>
"""
end
# ... implement other field typesStep 4: Add Helper Components
Create shared sub-components for hints and errors:
attr(:hint, :string, default: nil)
defp field_hint(%{hint: nil} = _assigns), do: ~H""
defp field_hint(assigns) do
~H"""
<p class="text-xs text-gray-500 mt-1">{@hint}</p>
"""
end
attr(:form, :any, required: true)
attr(:field, :any, required: true)
defp field_errors(assigns) do
~H"""
<p
:for={{msg, _opts} <- Keyword.get_values((@form[@field.name] || %{errors: []}).errors, :message)}
class="text-xs text-red-600 mt-1"
>
{msg}
</p>
"""
endStep 5: Implement Optional Callbacks (Optional)
@impl AshFormBuilder.Theme
def render_nested(assigns) do
# Return nil to use default nested form rendering
# Or implement custom nested form rendering
nil
end
@impl AshFormBuilder.Theme
def render_component(:combobox, assigns) do
# Custom component rendering for specific types
# Optional - for advanced customization
end
def render_component(_, _), do: nilStep 6: End the Module
endTheme Callbacks Reference
Required Callbacks
AshFormBuilder.Theme.render_field/2
@callback render_field(assigns :: map(), opts :: keyword()) :: Phoenix.LiveView.Rendered.t()Assigns Available:
| Key | Type | Description |
|-----|------|-------------|
| :form | Phoenix.HTML.Form | The form struct |
| :field | AshFormBuilder.Field | Field metadata (name, type, label, etc.) |
| :target | pid() | String.t() | LiveComponent target (@myself) |
| :uploads | map() | Upload configurations for file fields |
| :theme_opts | keyword() | Theme-specific options |
Field Struct (AshFormBuilder.Field):
| Key | Type | Description |
|-----|------|-------------|
| :name | atom() | Field name (e.g., :email) |
| :label | String.t() | Field label |
| :type | atom() | Field type (e.g., :text_input, :checkbox) |
| :required | boolean() | Whether field is required |
| :placeholder | String.t() | Placeholder text |
| :hint | String.t() | Helper text |
| :options | list() | Options for :select fields |
| :class | String.t() | Additional CSS classes |
| :wrapper_class | String.t() | Wrapper div CSS classes |
| :relationship | atom() | Relationship name (for relationship fields) |
| :relationship_type | atom() | :many_to_many, :has_many, etc. |
| :destination_resource | module() | Related resource module |
| :opts | keyword() | Custom options (e.g., search_event for combobox) |
Optional Callbacks
AshFormBuilder.Theme.render_nested/1
@callback render_nested(assigns :: map()) :: Phoenix.LiveView.Rendered.t() | nilAssigns Available:
| Key | Type | Description |
|-----|------|-------------|
| :form | Phoenix.HTML.Form | The parent form |
| :nested | AshFormBuilder.NestedForm | Nested form configuration |
| :target | pid() | String.t() | LiveComponent target |
| :theme | module() | The theme module |
Return: nil to use default rendering, or a rendered template.
AshFormBuilder.Theme.render_component/2
@callback render_component(atom(), assigns :: map()) :: Phoenix.LiveView.Rendered.t() | nilFor advanced customization of specific component types.
Common Customization Patterns
Pattern 1: Extend Default Theme (Minimal Changes)
Only override specific field types, delegate the rest to Default:
defmodule MyAppWeb.FormBuilder.MinimalTheme do
@behaviour AshFormBuilder.Theme
use Phoenix.Component
# Delegate render_field/2 to Default theme
defdelegate render_field(assigns, opts), to: AshFormBuilder.Themes.Default
# Optional: override render_nested/1
@impl AshFormBuilder.Theme
def render_nested(assigns), do: nil
endPattern 2: Wrapper Component Pattern
Wrap all fields in a consistent structure:
defp render_text_input(assigns) do
~H"""
<div class={["form-field", @field.wrapper_class]}>
<div class="form-field-label">
<label for={Phoenix.HTML.Form.input_id(@form, @field.name)}>
{@field.label}
</label>
<span :if={@field.required} class="required-indicator">*</span>
</div>
<div class="form-field-input">
<input ... />
</div>
<div class="form-field-hint">
<.field_hint hint={@field.hint} />
<.field_errors form={@form} field={@field.name} />
</div>
</div>
"""
endPattern 3: CSS Framework Integration (Bootstrap Example)
defmodule MyAppWeb.FormBuilder.BootstrapTheme do
@behaviour AshFormBuilder.Theme
use Phoenix.Component
@impl AshFormBuilder.Theme
def render_field(assigns, opts) do
case assigns.field.type do
:text_input -> render_text_input(assigns)
:checkbox -> render_checkbox(assigns)
# ... etc
end
end
defp render_text_input(assigns) do
~H"""
<div class="mb-3">
<label for={Phoenix.HTML.Form.input_id(@form, @field.name)} class="form-label">
{@field.label}
<span :if={@field.required} class="text-danger">*</span>
</label>
<input
type="text"
id={Phoenix.HTML.Form.input_id(@form, @field.name)}
name={Phoenix.HTML.Form.input_name(@form, @field.name)}
value={Phoenix.HTML.Form.input_value(@form, @field.name)}
class={"form-control #{@field.class}"}
placeholder={@field.placeholder}
/>
<div :if={@field.hint} class="form-text">{@field.hint}</div>
<.field_errors form={@form} field={@field.name} />
</div>
"""
end
@impl AshFormBuilder.Theme
def render_nested(assigns), do: nil
defp field_errors(assigns) do
~H"""
<div
:for={{msg, _opts} <- Keyword.get_values((@form[@field.name] || %{errors: []}).errors, :message)}
class="invalid-feedback d-block"
>
{msg}
</div>
"""
end
endPattern 4: Accessibility-Focused Theme
Add ARIA attributes and accessibility features:
defp render_text_input(assigns) do
error_id = "error-#{Phoenix.HTML.Form.input_id(@form, @field.name)}"
hint_id = "hint-#{Phoenix.HTML.Form.input_id(@form, @field.name)}"
has_errors = length((@form[@field.name] || %{errors: []}).errors) > 0
~H"""
<div class={["form-group", @field.wrapper_class]}>
<label
:if={@field.label}
for={Phoenix.HTML.Form.input_id(@form, @field.name)}
class="form-label"
>
{@field.label}
<span :if={@field.required} aria-hidden="true" class="required">*</span>
</label>
<input
type="text"
id={Phoenix.HTML.Form.input_id(@form, @field.name)}
name={Phoenix.HTML.Form.input_name(@form, @field.name)}
value={Phoenix.HTML.Form.input_value(@form, @field.name)}
class={"form-input #{@field.class}"}
class={if has_errors, do: "border-red-500", else: "border-gray-300"}
placeholder={@field.placeholder}
aria-describedby={if @field.hint, do: hint_id}
aria-invalid={if has_errors, do: "true", else: "false"}
aria-errormessage={if has_errors, do: error_id}
/>
<p
:if={@field.hint}
id={hint_id}
class="form-hint text-sm text-gray-500"
>
{@field.hint}
</p>
<p
:for={{msg, _opts} <- Keyword.get_values((@form[@field.name] || %{errors: []}).errors, :message)}
id={error_id}
class="form-error text-sm text-red-600"
role="alert"
>
{msg}
</p>
</div>
"""
endPattern 5: RTL Language Support
defp render_text_input(assigns) do
dir = Keyword.get(@theme_opts, :dir, "ltr")
~H"""
<div class={["form-group", @field.wrapper_class]} dir={dir}>
<label
:if={@field.label}
for={Phoenix.HTML.Form.input_id(@form, @field.name)}
class="form-label"
>
{@field.label}
</label>
<input
type="text"
id={Phoenix.HTML.Form.input_id(@form, @field.name)}
name={Phoenix.HTML.Form.input_name(@form, @field.name)}
value={Phoenix.HTML.Form.input_value(@form, @field.name)}
class={"form-input #{@field.class}"}
dir={dir}
/>
</div>
"""
end
# Configure in config.exs:
# config :ash_form_builder, theme_opts: [dir: "rtl"]Using Your Custom Theme
Step 1: Configure Globally
# config/config.exs
config :ash_form_builder,
theme: MyAppWeb.FormBuilder.TailwindTheme,
theme_opts: [
wrapper_class: "space-y-6",
field_wrapper_class: "mb-4",
label_class: "block text-sm font-medium mb-1",
input_class: "w-full px-3 py-2 border rounded-md"
]Step 2: Use in LiveView
defmodule MyAppWeb.ClinicLive.Form do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
form = MyApp.Billing.Clinic.Form.for_create(actor: socket.assigns.current_user)
{:ok, assign(socket, form: form)}
end
@impl true
def render(assigns) do
~H"""
<.live_component
module={AshFormBuilder.FormComponent}
id="clinic-form"
resource={MyApp.Billing.Clinic}
form={@form}
/>
"""
end
endStep 3: Runtime Theme Switching (Advanced)
Pass theme explicitly to FormRenderer:
# In your LiveView
def render(assigns) do
~H"""
<.live_component
module={AshFormBuilder.FormComponent}
id="clinic-form"
resource={MyApp.Billing.Clinic}
form={@form}
theme={MyAppWeb.FormBuilder.TailwindTheme}
/>
"""
endTheme Options
Theme options are passed via config.exs and accessible in opts parameter:
# config/config.exs
config :ash_form_builder,
theme: MyAppWeb.FormBuilder.TailwindTheme,
theme_opts: [
# Global wrapper class
wrapper_class: "space-y-6",
# Field wrapper class
field_wrapper_class: "mb-4",
# Label class
label_class: "block text-sm font-medium mb-1",
# Input class (applied to all inputs)
input_class: "w-full px-3 py-2 border rounded-md",
# Error class
error_class: "text-sm text-red-600 mt-1",
# Hint class
hint_class: "text-xs text-gray-500 mt-1",
# Custom options for your theme
custom_option: "value"
]Access in your theme:
defp render_text_input(assigns) do
input_class = Keyword.get(@theme_opts, :input_class, "form-input")
~H"""
<input class={input_class} />
"""
endTroubleshooting
Problem: Theme module not found
Error: module AshFormBuilder.Theme.MishkaTheme is not loaded
Solution:
- Ensure the theme module is compiled
- Add
Code.ensure_loaded?(MyTheme)in test setup if testing - Check module name matches config
Problem: Field not rendering
Error: Field appears blank or missing
Solution:
- Check
render_field/2pattern matches the field type - Ensure you're returning a HEEx template (
~H"...") - Verify
Phoenix.LiveView.Renderedstruct is returned
Problem: CSS classes not applying
Error: Styles not showing up
Solution:
- Check class names match your CSS framework
- Verify
@field.classand@field.wrapper_classare included - Check for typos in class names
Problem: Errors not displaying
Error: Validation errors don't show
Solution:
# Ensure you're extracting errors correctly:
defp field_errors(assigns) do
~H"""
<p
:for={{msg, _opts} <- Keyword.get_values((@form[@field.name] || %{errors: []}).errors, :message)}
class="text-red-600"
>
{msg}
</p>
"""
endProblem: Nested forms not rendering
Error: Nested relationship forms don't appear
Solution:
- Implement
render_nested/1callback OR returnnilfor default - Check
AshFormBuilder.FormRenderer.nested_form/1is being called - Verify nested form configuration in resource DSL
Complete Example: Tailwind Theme
See a complete working example at:
lib/ash_form_builder/themes/default.ex (Default theme)
lib/ash_form_builder/theme/mishka_theme.ex (MishkaChelekom theme)
Next Steps
- Start with Default - Copy
AshFormBuilder.Themes.Defaultand modify - Test incrementally - Change one field type at a time
- Use theme_opts - Make your theme configurable
- Document your theme - Add
@moduledocwith usage examples - Share with community - Publish your theme as a separate package