Avo Custom UI
Everything about building your own UI inside an Avo admin and the asset plumbing behind it: standalone pages, embedded record widgets, Avo's reusable components, ejecting Avo's markup, Stimulus/dynamic forms, custom CSS/JS, the Tailwind integration, and packaging it all as a plugin.
Two things to fix before writing any code:
- Which surface? A standalone page (own route + sidebar item) is a custom tool. A widget on an existing record's Show/Edit is a resource tool. Rendering Avo's existing markup differently is an eject. A new field type is a different skill (see Gotchas).
- Everything here is Community. Custom tools, resource tools, ejecting, JS/Stimulus, and the Tailwind integration need no paid gem.
Docs — fetch on demand with WebFetch; prefer the raw .md (clean, no HTML). Read the page before implementing anything non-trivial in that area.
When this applies
| Request (Avo-shaped or plain Rails/product) | Section |
|---|
| "Add a custom page/section to the admin", "a dashboard page", "a reports screen with its own sidebar link" | Custom tools |
| "Embed a custom panel/widget on the record page", "show related stats on the post's Show view" | Resource tools |
| "Add extra inputs to the edit form that aren't columns", "build a nested form", "write a Hash/array attribute from the form" | Resource tools → custom form inputs |
| "Style my custom tool like the rest of Avo", "buttons/cards that match the admin", "dark-mode-ready markup" | Native components |
| "Override how Avo renders the index table", "change a view's markup", "customize the sidebar/layout partial" | Eject views |
| "Add my own JS/CSS to the admin", "add a Stimulus controller to a resource" | JavaScript & Stimulus + Assets & Tailwind |
| "When country changes reload cities", "cascading/dependent selects", "make the form dynamic" | dependent selects |
| "Toggle/disable a field based on another field" | pre-made toggle/disable |
| "Use Tailwind classes in custom admin UI", "my utility classes don't apply", "migrate the admin to Tailwind 4" | Assets & Tailwind |
| "Package this as a reusable plugin/gem", "register a new view type / field / menu item from a gem" | Advanced: package as a plugin |
Related skills: re-skinning colors/spacing through CSS variables is avo-branding-appearance; defining a brand-new field type is avo-custom-fields; enabling a registered custom index view type on a resource is avo-index-views.
Custom tools
A standalone page — its own route, controller action, sidebar item — rendered inside Avo's layout. Reach for it for dashboards, reports, admin utility screens.
Generate
bin/rails generate avo:tool dashboard
Creates (and restarts the server so the route takes effect):
app/views/avo/sidebar/items/_dashboard.html.erb — the sidebar link (all files in this dir load into the sidebar, alphabetically — rename to reorder)
app/controllers/avo/tools_controller.rb — a ToolsController on first run, with the action inserted
app/views/avo/tools/dashboard.html.erb — the page view
- a route injected inside your
mount_avo block in config/routes.rb
Controller + route
class Avo::ToolsController < Avo::ApplicationController
def dashboard
@page_title = "Dashboard"
add_breadcrumb title: "Dashboard"
end
end
authenticate :user, ->(user) { user.is_admin? } do
mount_avo do
get "dashboard", to: "tools#dashboard", as: :dashboard
end
end
Because the action inherits from Avo::ApplicationController, the view runs with the full Avo view context: ui.* components, @page_title and any instance var you set, _current_user, Avo::Current.context, params, and the avo. / main_app. path helpers. Set instance variables in the action, read them in the view — normal Rails.
View
Build the page from Avo's own components so it matches the admin and gets dark mode for free:
<%# app/views/avo/tools/dashboard.html.erb %>
<div class="flex flex-col">
<%= render ui.panel(title: "Dashboard") do |panel| %>
<% panel.with_controls do %>
<%= a_link("/admin", icon: "tabler/outline/external-link", style: :primary, color: :primary) do %>
Admin
<% end %>
<% end %>
<% panel.with_card(title: "New tool", padded: true) do %>
<div class="flex flex-col justify-between min-h-24 space-y-4">
<h3>What a nice new tool 👋</h3>
</div>
<% end %>
<% end %>
</div>
Use your app's helpers / path helpers
class Avo::ToolsController < Avo::ApplicationController
helper HomeHelper
def dashboard
@page_title = "Dashboard"
end
end
Inside the engine, prefix path helpers: avo.resources_posts_path(1) for Avo routes, main_app.posts_path for your app's routes. Load your own CSS/JS via Assets & Tailwind.
Resource tools
A partial embedded inside a resource's Show/Edit view — a widget bound to one record. Default-visible on Show.
Generate + register
bin/rails generate avo:resource_tool post_info
Creates the config class app/avo/resource_tools/post_info.rb and the partial app/views/avo/resource_tools/_post_info.html.erb. Register it in the resource's fields block (it's placed like a field):
class Avo::Resources::Post < Avo::BaseResource
def fields
tool Avo::ResourceTools::PostInfo, show_on: :edit
end
end
Control placement with the usual field visibility options (show_on, only_on, show_on: :forms).
Partial context
The partial has access to: tool (your PostInfo instance), @resource (with .record, .view, .params), form (only on New/Edit — check form.present?), params, Avo::Current.context, and current_user.
Keep logic out of the view
Define post_initialize on the tool — Avo calls it after hydration (no super, no overriding initialize). Inside it you have resource, parent, and view. Expose data via readers/methods and read them through tool in the partial:
class Avo::ResourceTools::PostInfo < Avo::BaseResourceTool
self.name = "Post info"
attr_reader :foo
def post_initialize
@foo = :bar
end
def custom_method_call = :called
end
<%= tool.foo %>
<%= tool.custom_method_call %>
Custom form inputs and nested forms
A resource tool is the way to add inputs to a form that aren't backed by columns — including nested (fields_for) inputs producing arrays/Hashes. Three steps, all required:
- Render the inputs against
form in the partial (show it only on forms):
class Avo::Resources::Fish < Avo::BaseResource
self.extra_params = [:fish_type, properties: [], information: [:name, :history]]
def fields
tool Avo::ResourceTools::FishInformation, show_on: :forms
end
end
<%# app/views/avo/resource_tools/_fish_information.html.erb %>
<%= render ui.panel(title: @resource.record.name) do |panel| %>
<% panel.with_card(padded: true) do %>
<% if form.present? %>
<%= form.label :fish_type %>
<%= form.text_field :fish_type, class: input_classes %>
<%= form.label :properties %>
<%= form.text_field :properties, multiple: true, class: input_classes %> <%# array %>
<% form.fields_for :information do |information_form| %>
<%= information_form.text_field :name, class: input_classes %> <%# Hash %>
<%= information_form.text_field :history, class: input_classes %>
<% end %>
<% end %>
<% end %>
<% end %>
- Permit the params on the resource via
self.extra_params (Avo runs model.assign_attributes params.permit(extra_params)). Anything not listed here silently never reaches the model.
- Make the model respond to those attributes (real columns, or setters like
def fish_type=(v)).
Native components
Use Avo's own ViewComponents instead of hand-rolled <div>s — they keep custom UI visually consistent and dark-mode-ready for free. All are reachable in any Avo view context (custom tools, resource tools, ejected partials).
a_button / a_link → Avo::ButtonComponent. a_button renders a <button>, a_link an <a> (first arg = href). Options: style: (:primary/:outline/:text, default :outline), size: (:xs/:sm/:md/:lg), color: (:primary, :accent, or any Tailwind color), icon: / end_icon: (icon path), rounded: :full (pill). Extra kwargs pass through to link_to/button_to — method:, data:, title:.
<%= a_link("/posts/1", method: :delete, data: { turbo_confirm: "Sure?" }) { "Delete" } %>
<%= a_button(style: :primary, icon: "tabler/outline/plus") { "New" } %>
-
ui.panel(...) → Avo::UI::PanelComponent. A titled container with slots: with_controls (right-aligned header buttons), with_card (bordered card body), with_body (flush body, no card), plus with_cover, with_sidebar, with_pre_body, with_footer, with_header. Options: title:, description:, class:, data:.
-
ui.card(...) → Avo::UI::CardComponent. A standalone bordered surface (the same card panel.with_card wraps). Options: title:, description:, padded:, class: (modifiers: card--padded, card--compact-wrapper, card--compact-header-y, card--compact-header-x — header compaction is split per axis, so compose the last two for both). Slots: with_header, with_body, with_footer.
-
ui.description_list → wrap a list of fields so they render full-width with dividers, exactly like Avo's own field lists.
Prefer built-in options over utility classes. padded: true gives the card body Avo's exact standard padding — reach for it instead of adding your own px-*/py-*. title: / description: build the header for you. Inside a ui.panel, use its with_card slot rather than nesting a ui.card yourself. The card body ships unpadded by default (so tables/scrollers sit flush) — opt into padded: true for free-form content like forms and prose.
Eject views
Avo renders from partials, ViewComponents, and controllers inside the gem. When config isn't enough, copy one into your app and edit it — your copy takes precedence.
bin/rails generate avo:eject --partial app/views/layouts/avo/application.html.erb
bin/rails generate avo:eject --partial :logo
bin/rails generate avo:eject --partial :head
bin/rails generate avo:eject --partial :pre_head
bin/rails generate avo:eject --partial :sidebar_extra
bin/rails generate avo:eject --component Avo::Index::TableRowComponent
bin/rails generate avo:eject --field-components text
bin/rails generate avo:eject --field-components text --view edit
bin/rails generate avo:eject --controller application_controller
Prepared-template symbols: :logo, :header, :pre_head, :head, :scripts, :sidebar_extra, :profile_menu_extra, plus the override files :avo_overrides_css, :avo_overrides_js, :asset_overrides.
Scope to avoid replacing a component everywhere. By default an ejected Avo::Views / Avo::Fields component replaces the original across the whole app. Add --scope to nest your copy in its own namespace, then wire it in only where wanted via self.components (resource) or the components: field option:
bin/rails generate avo:eject --component Avo::Views::ResourceIndexComponent --scope admins
JavaScript & Stimulus
Avo integrates a light Stimulus layer so you can make forms dynamic. First, add your JS entrypoint through the asset pipeline — nothing loads otherwise.
Attach controllers + use Avo's targets
class Avo::Resources::Course < Avo::BaseResource
self.stimulus_controllers = "course-resource select-field"
end
Avo also adds a default resource-edit / resource-show / resource-index controller per view, and passes each controller a view value (index/show/edit/new) in the DOM. For every field it emits ready-made Stimulus targets you can hook into:
- Wrapper:
data-[controller]-target="[fieldName][FieldType]Wrapper" → nameTextWrapperTarget
- Input (Edit/New):
[fieldName][FieldType]Input → nameTextInputTarget
Attach actions/classes/data to a field's input or wrapper via the html: option (see the field-options-api docs) to trigger your controller methods.
Pre-made toggle/disable (no JS)
Show/hide or enable/disable one field from another with zero custom JS, using the built-in resource-edit controller:
field :has_country, as: :boolean, html: {
edit: { input: { data: {
action: "input->resource-edit#toggle",
resource_edit_toggle_target_param: "countrySelectWrapper"
} } }
}
field :country, as: :select, options: { ... }
#toggle hides/shows the target; #disable greys it out; #debugOnInput logs events to the console for targeting checks. Target a ...Wrapper when the field has multiple inputs (e.g. polymorphic belongs_to).
Dependent selects (cascading dropdowns)
The canonical "change country → repopulate cities" flow. Wire an input-> action + targets on the two selects, add an Avo engine route + controller action that returns JSON, and register a custom Stimulus controller that fetches and repopulates on connect and on change:
self.stimulus_controllers = "course-resource"
field :country, as: :select, options: {...}, html: { edit: { input: { data: {
course_resource_target: "countryFieldInput",
action: "input->course-resource#onCountryChange"
} } } }
field :city, as: :select, options: {...}, html: { edit: { input: { data: {
course_resource_target: "cityFieldInput"
} } } }
Avo::Engine.routes.draw do
scope :resources do
get "courses/cities", to: "courses#cities"
end
end
class Avo::CoursesController < Avo::ResourcesController
def cities = render json: Course.cities[params[:country].to_sym] || []
end
The Stimulus controller fetches ${window.Avo.configuration.root_path}/resources/courses/cities?country=... and rebuilds the city <option>s. Fetch the JavaScript doc for the full controller — it captures the initial value, guards to edit/new via viewValue, and shows a loading overlay.
Register a custom controller
In your entrypoint, hook Avo's Stimulus instance and register your controller:
import SampleController from "controllers/sample_controller";
window.Stimulus.register("sample", SampleController);
<div data-controller="sample"><!-- ... --></div>
Assets & Tailwind
Load your own CSS/JS
Avo hooks into your app's existing pipeline — Importmap, esbuild/jsbundling, Propshaft, and Sprockets are all fully supported. The generator does the wiring:
bin/rails generate avo:js:install
bin/rails generate avo:js:install --bundler esbuild
Manual (Sprockets/Propshaft): eject :pre_head, create avo.custom.js + avo.custom.css, and load them (javascript_include_tag "avo.custom", defer: true — always defer: true so order matches Avo's).
Load order in <head> (later wins the cascade): _pre_head (yours) → Avo's own CSS/JS → avo-overrides.css / avo-overrides.js → _head (yours) → brand-palette overrides. Put styles in _pre_head to let Avo's defaults load after them; use _head when you deliberately want to win.
Tailwind integration
When Avo detects tailwindcss-ruby, it auto-enables the Tailwind integration and compiles an app-level stylesheet (app/assets/builds/avo/application.css) that includes Avo core + plugin styles, your styles from app/assets/stylesheets/avo/**/*.css, and any utility classes discovered under app/. This is what makes Tailwind classes you write in custom tools / ejected components / custom fields actually exist. Zero config to start:
gem "tailwindcss-ruby"
Add custom Avo styles under app/assets/stylesheets/avo/ (they're built into the same stylesheet):
@layer components { .avo-btn-highlight { @apply px-3 py-2 rounded-md bg-indigo-600 text-white; } }
Run it with a watcher during dev (bin/rails avo:tailwindcss:watch in Procfile.dev). Extra scan roots via config.tailwindcss_content_sources (defaults to Rails.root.join("app")). Opt out with config.tailwindcss_integration_enabled = false.
Quick no-build tweaks: avo-overrides.css / avo-overrides.js
Two files Avo loads on every screen, served as-is (not run through the Tailwind build). Because avo-overrides.css loads after Avo's stylesheet, overriding Avo's CSS variables here re-skins the whole admin with no build step (that's the avo-branding-appearance territory). Eject to customize: rails g avo:eject --partial :avo_overrides_css (or :avo_overrides_js, or :asset_overrides for both).
Tailwind 4 migration
Avo's build emits Tailwind v4 syntax. If you have your own Tailwind pipeline, migrate it to v4 and apply the breaking changes (renamed utilities like rounded→rounded-sm, explicit border colors since the default is now currentColor) to every custom field, resource tool, custom tool, custom card, and ejected component. Fetch the migration doc before touching a project that has an existing Tailwind config.
Advanced: package as a plugin
Wrap any of the above into a Rails Engine so it's reusable across apps. Register everything from the avo_boot hook so it runs once on boot; avo_init runs on every request.
initializer "avo-feed-view.init" do
ActiveSupport.on_load(:avo_boot) do
Avo.plugin_manager.register :feed_view
Avo.plugin_manager.mount_engine Avo::FeedView::Engine, at: "/feed_view"
Avo::Resources::Base.include Avo::FeedView::Concerns::FeedViewConcern
Avo.asset_manager.add_javascript "/feed-view-assets/feed_view"
Avo.asset_manager.add_stylesheet "/feed-view-assets/feed_view"
Avo.asset_manager.register_stimulus_controller "feed", FeedController
end
end
Avo.plugin_manager API:
register(name, priority: 10) — add to the plugin list (lower priority runs earlier).
register_view_type(name, component:, icon:, active_icon:) — a new index view type. Pass component: as a string ("MyPlugin::ViewTypes::TimelineViewTypeComponent") to dodge boot load-order issues; the component inherits Avo::ViewTypes::BaseViewTypeComponent and must render paginator_component. Enabling it on a resource is avo-index-views (self.view_types / self.default_view_type).
register_field(method_name, klass) — ship a field type from a gem (the plugin-author side of avo-custom-fields).
register_menu_item(name, &block) — a custom menu DSL method for config.main_menu. Delegates to avo-menu; no-op when avo-menu isn't installed, so register unconditionally.
mount_engine(klass, at:) — mount the engine's routes inside Avo.
installed?(name) — adapt to what else is present.
Assets from library code go through Avo.asset_manager (not the app pipeline); Avo injects them but does not compile them — ship compiled builds, e.g. served from app/assets/builds via a Rack::Static middleware.
Gotchas
- Custom tool vs. resource tool vs. new field. Standalone page → custom tool. Widget on a record's Show/Edit → resource tool. A new input type used across resources → avo-custom-fields (not this).
- Ejected files are frozen copies — you own them forever. They stop receiving upstream updates; Avo bug/security fixes won't reach your copy. Prefer CSS-variable / Tailwind-layer overrides and
--scope (so you don't replace a component everywhere) before ejecting whole components.
- The no-
mount_avo fallback route is NOT behind auth. A custom-tool route inside mount_avo inherits Avo's authentication. But if config/routes.rb has no mount_avo at all, the generator appends a standalone Avo::Engine.routes.draw block that is unprotected — secure it yourself.
- Resource-tool custom inputs need all three steps. Missing
self.extra_params (params not permitted) or a model that doesn't respond to the attribute → the value silently never persists. Nested keys not listed in extra_params are dropped too.
form is only present on New/Edit. Guard resource-tool partials with if form.present? or they blow up on Show.
- Prefer components over hand-rolled divs.
ui.panel / ui.card with padded: / title: give you Avo's spacing, headers, and dark mode; utility classes on bare <div>s drift out of sync and miss dark mode.
avo-overrides.js runs once, but Avo navigates with Turbo. One-shot DOM edits vanish on the next visit — use a Stimulus controller (re-connects each visit) or a turbo:load listener.
avo-overrides.css is served as-is — NOT compiled by Tailwind. @apply and utility classes won't be generated there; it's for raw CSS and CSS-variable overrides. Put Tailwind-using CSS under app/assets/stylesheets/avo/.
- Tailwind integration silently stays off unless
tailwindcss-ruby is present, and — if you pull Tailwind via tailwindcss-rails — that gem is >= 4.0 (on 3.x the integration is disabled even though tailwindcss-ruby is there). Symptom: your utility classes don't exist in the admin.
Report
When done, tell the user:
- Which surface you built (custom tool / resource tool / ejected view / plugin) and the exact files created or edited (absolute paths), including the resource(s) you registered a tool on.
- Which components/patterns it uses (
ui.panel/ui.card, a_button/a_link, pre-made toggle/disable, dependent-select controller, ejected + --scope).
- For form inputs: that you added
self.extra_params and whether the model already responds to those attributes (or the setters the user still needs to add).
- Asset/Tailwind wiring done vs. left to the user: the
avo:js:install entrypoint, tailwindcss-ruby (and tailwindcss-rails >= 4.0), the watcher process.
- Any follow-ups they must do: secure a fallback (non-
mount_avo) route, maintain ejected copies on upgrades, permit missing params, or compile/serve plugin assets.