| name | layered-ui-rails |
| description | Installs, configures, and builds with the layered-ui-rails gem - a Rails 8+ engine providing WCAG 2.2 AA compliant layout, components, and Stimulus controllers with dark/light theming. Use when adding layered-ui-rails to a Rails app, building views with its layout and helpers, styling with its CSS classes, or troubleshooting setup. |
| license | Apache-2.0 |
| compatibility | Requires Ruby on Rails >= 8.0, tailwindcss-rails >= 4.0, importmap-rails >= 2.0, stimulus-rails >= 1.0 |
| metadata | {"author":"layered.ai","version":"1.0","source":"https://github.com/layered-ai-public/layered-ui-rails"} |
layered-ui-rails
A Rails 8+ engine providing WCAG 2.2 AA compliant design tokens, Tailwind CSS components, and Stimulus controllers for theme switching, mobile navigation, slide-out panels, modals, and tabs.
Installation
bundle add layered-ui-rails
bin/rails generate layered:ui:install
The generator adds @import "../builds/tailwind/layered_ui"; to application.css (the engine's CSS is served straight from the gem via tailwindcss-rails' engine support), creates a layered_ui_overrides.css file for theme customisations, and adds the JS import to application.js.
Then render the engine layout from your application layout. Place all content_for blocks and l_ui_add_body_class calls above the render call - the engine layout reads them when it renders, so they must be defined first:
<% l_ui_add_body_class "l-ui-body--always-show-navigation" %>
<% content_for :l_ui_navigation_items do %>
<%= l_ui_navigation_item("Dashboard", dashboard_path) %>
<%= l_ui_navigation_item("Users", users_path) %>
<% end %>
<%= render template: "layouts/layered_ui/application" %>
Layout structure
The engine layout provides a fixed header (63px), optional sidebar navigation (256px wide), optional resizable panel (320px default), and a main content area. Dark mode is built in with a toggle and localStorage persistence.
Your view's yield content is already wrapped in .l-ui-page by the engine layout (which applies the responsive padding and --with-navigation margin). Do not add your own .l-ui-page wrapper around your view content - it duplicates the container and nests two .l-ui-page elements. Start your view with its actual content (headings, sections, components) directly.
Content blocks
Populate layout regions with content_for (always above the render call):
<%# Navigation sidebar items %>
<% content_for :l_ui_navigation_items do %>
<%= l_ui_navigation_item("Dashboard", dashboard_path) %>
<%= l_ui_navigation_item("Users", users_path) %>
<% end %>
<%# Side panel %>
<% content_for :l_ui_panel_heading do %>
Help
<% end %>
<% content_for :l_ui_panel_body do %>
<p>Panel content here.</p>
<% end %>
<%# Inject arbitrary content into <head>: third-party scripts (analytics,
chat widgets), a page-specific inline <script>, meta/verification tags,
preload hints, or a per-request stylesheet link. For styling, the overrides
file is usually a better fit - see note below. %>
<% content_for :l_ui_head do %>
<%= javascript_include_tag "https://cdn.example.com/widget.js", defer: true %>
<meta name="google-site-verification" content="...">
<% end %>
<%# Add CSS classes to <body> (call multiple times or pass several; they compose) %>
<% l_ui_add_body_class "l-ui-body--always-show-navigation" %>
<%# Override logos %>
<% content_for :l_ui_logo_light do %>
<%= image_tag "my_logo.svg", alt: "", class: "l-ui-header__logo l-ui-header__logo--light" %>
<% end %>
<% content_for :l_ui_logo_dark do %>
<%= image_tag "my_logo_dark.svg", alt: "", class: "l-ui-header__logo l-ui-header__logo--dark" %>
<% end %>
<%# Prepend or append items to the header actions group %>
<% content_for :l_ui_header_actions_start do %>
<%= link_to "Docs", docs_path, class: "l-ui-button l-ui-button--ghost l-ui-button--small" %>
<% end %>
<% content_for :l_ui_header_actions_end do %>
<%= link_to "Help", help_path, class: "l-ui-button l-ui-button--ghost l-ui-button--small" %>
<% end %>
<%# Or replace the default actions group entirely and compose with helpers %>
<% content_for :l_ui_header_actions do %>
<%= link_to "Docs", docs_path, class: "l-ui-button l-ui-button--ghost l-ui-button--small" %>
<%= l_ui_theme_toggle %>
<%= l_ui_authentication %>
<%= l_ui_navigation_toggle %>
<% end %>
<%# Inline header links (alongside the logo) %>
<% content_for :l_ui_header_links do %>
<%= link_to "Pricing", pricing_path %>
<%= link_to "About", about_path %>
<% end %>
:l_ui_head injects whatever you like into <head>. As a rule of thumb,
reach for it for head content rather than styling, because styles are easier
to maintain when they live with the rest of your CSS:
- layered-ui token or component overrides (e.g.
--accent, restyling a
.l-ui-* class) fit best in app/assets/tailwind/layered_ui_overrides.css
- see Theming - so they are part of the Tailwind build and can
use
@apply and the design tokens.
- Other custom styling fits in the host app's own application stylesheet,
like any normal Rails app.
For per-request values that cannot be known at build time (e.g. per-tenant
brand tokens), a good option is to serve them as a stylesheet from a Rails
controller and link it via :l_ui_head:
<% content_for :l_ui_head do %>
<%= stylesheet_link_tag tenant_theme_path(current_tenant) %>
<% end %>
The controller renders CSS that overrides the design tokens (--accent,
etc.) - Turbo- and CSP-friendly, and it keeps styling out of the markup.
Layout modes
The engine layout has two header/navigation modes, both selected through the body class (l_ui_add_body_class). The default - setting nothing - is a full-width header with the sidebar shown only on toggle. Add l-ui-body--header-contained for a centred, contained header (pair it with l-ui-page__contained to constrain the body width), or l-ui-body--always-show-navigation to pin the sidebar open on desktop. The modes compose, and because the layout reads the body class at render time, a page that sets nothing gets the full-width default.
The two intended defaults:
- Landing / marketing pages - contained header and contained page width:
l-ui-body--header-contained plus l-ui-page__contained on the body.
- Admin / back-office pages - full-width with the menu pinned open:
l-ui-body--always-show-navigation, and no --header-contained (full-width is the default).
Which pages get which mode (per-section layouts, controllers, etc.) is an application concern, not something the gem dictates.
Body class modifiers:
l-ui-body--header-contained - centres the header's inner row at --l-ui-contained-width (pair with l-ui-page__contained); landing pages
l-ui-body--always-show-navigation - pins navigation as a sidebar on desktop
l-ui-body--hide-header - hides the header and collapses its space
l-ui-body--glass-header - glass header (translucent + blur); content scrolls under it
l-ui-body--flush-top - zeroes the page's top gutter so a hero sits flush at the top, behind the header (pair with --glass-header or --hide-header)
Controller instance variables
@page_title = "Users"
@page_description = "Manage users"
@l_ui_icon_light_url = url
@l_ui_icon_dark_url = url
@l_ui_apple_touch_icon_url = url
@l_ui_panel_icon_light_url = url
@l_ui_panel_icon_dark_url = url
View helpers
All helpers use the l_ui_ prefix and are available in all views automatically. See references/HELPERS.md for full signatures and examples.
Quick reference:
| Helper | Purpose |
|---|
l_ui_navigation_item(label, path, ...) | Sidebar nav link (supports icon:, match: :starts_with, expandable:). For valid icon: names and the missing-asset gotcha, see the "Icons" section in references/HELPERS.md |
l_ui_navigation_section(heading = nil, ...) | Group nav items; supports collapsible:, storage_key:, separated: |
l_ui_breadcrumbs(&block) | Breadcrumb nav wrapper |
l_ui_breadcrumb_item(label, path = nil) | Individual breadcrumb |
l_ui_title_bar(title:, breadcrumbs: [], actions: nil, &block) | Responsive page title bar with breadcrumbs and actions |
l_ui_pagy(pagy) | Styled pagination (requires pagy gem) |
l_ui_search_form(query, url:, fields:, ...) | Search form (requires ransack gem) |
l_ui_sort_link(query, attribute, label = nil, ...) | Sortable table header (requires ransack gem) |
l_ui_table(records, columns:, caption:, ...) | Styled accessible data table with optional sort and actions |
l_ui_form(record, fields:, url:, method:) | Complete form with fields, error summary, and submit |
l_ui_normalise_field(record, config) | Normalise a raw field config hash into canonical form |
l_ui_user_signed_in? | Check if user is authenticated |
l_ui_current_user | Current user object |
l_ui_theme_toggle | Default header theme toggle button |
l_ui_authentication | Default header login/register buttons (Devise) |
l_ui_navigation_toggle | Default header sidebar toggle button |
l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}) | Floating panel anchored to a trigger, built on the native popover attribute |
l_ui_tag(rounded: false, container: {}) | Interactive tag built from segments (t.text, t.button, t.link, t.remove) with an optional attached popover (t.popover); rounded: true for a pill shape |
CSS classes
All classes use the l-ui- prefix with BEM naming. Use these in host app views rather than raw Tailwind utilities. See references/CSS.md for the full catalogue.
Key components:
| Component | Key classes |
|---|
| Page layout | .l-ui-page, --with-navigation, __vertically-centered, __narrow (narrow ~384px column, md:max-w-sm), __contained (wide column capped at --l-ui-contained-width) |
| Buttons | .l-ui-button, --primary, --outline, --outline-danger, --full, --icon |
| Hero | .l-ui-hero (pair with .l-ui-bleed), __inner, __title, __title-accent, __subtitle, __actions, __media, __media-img |
| Surfaces | .l-ui-surface, --highlighted, --sm, --collapsible, --collapsible-highlighted |
| Cards | .l-ui-card, --gradient, __eyebrow, __title, __icon, __body |
| Tints | .l-ui-tint-1 … .l-ui-tint-5 (shared decorative palette; sets the --l-ui-tint-* role variables) |
| Forms | .l-ui-form, .l-ui-form__group, .l-ui-form__field, .l-ui-label, .l-ui-select |
| Tables | .l-ui-table, .l-ui-table__header, .l-ui-table__cell, --primary, --action, .l-ui-table__action, --danger |
| Badges | .l-ui-badge, --rounded, --default, --success, --warning, --danger |
| Tags | .l-ui-tag, --rounded, __text, __button, __remove |
| Notices | .l-ui-notice (base), --success, --warning, --error |
| Tabs | .l-ui-tabs, .l-ui-tabs__list, .l-ui-tabs__tab, --active |
| Modal | .l-ui-modal, .l-ui-modal__header, .l-ui-modal__body |
| Popover | .l-ui-popover |
Stimulus controllers
All controllers use the l-ui-- namespace and are auto-registered via importmap.
| Controller | Identifier | Purpose |
|---|
| Theme | l-ui--theme | Dark/light mode toggle with localStorage |
| Navigation | l-ui--navigation | Responsive sidebar with backdrop |
| Navigation section | l-ui--navigation-section | Collapsible nav section with localStorage persistence |
| Panel | l-ui--panel | Resizable side panel (Cmd/Ctrl+I toggle) |
| Panel button | l-ui--panel-button | Draggable floating action button |
| Panel resize | l-ui--panel-resize | Panel width drag handle |
| Modal | l-ui--modal | Native <dialog> with focus trap |
| Tabs | l-ui--tabs | Accessible tabbed interface |
| Popover | l-ui--popover | Positions a native popover-attribute element relative to its trigger, with auto-flip |
| Search form | l-ui--search-form | Multi-scope search with Turbo support and pagination param preservation |
Theming
Override CSS custom properties after the engine import. Values are full CSS colors - oklch() is recommended for perceptually uniform mixing and consistent contrast, but #hex, rgb(), and keywords also work. A converter such as https://oklch.com/ can help translate from hex/rgb.
@import "../builds/tailwind/layered_ui";
:root {
--accent: oklch(0.58 0.19 255);
--accent-foreground: oklch(1 0 0);
}
.dark {
--accent: oklch(0.72 0.14 255);
}
Key tokens: --accent, --accent-foreground, --background, --foreground, --foreground-muted, --border, --border-control, --surface, --surface-highlighted, --danger, --header-height.
Asset overrides
Place files in app/assets/images/layered_ui/ to replace engine defaults:
logo_light.svg, logo_dark.svg, icon_light.svg, icon_dark.svg, apple_touch_icon.png.
The panel toggle button uses an inline SVG that inherits currentColor. Recolor it by overriding the --button-primary-icon Tier 2 token, or replace the image by setting both @l_ui_panel_icon_light_url and @l_ui_panel_icon_dark_url (per-request).
Optional integrations
- Devise - auto-detected. Provides styled auth views, header login/register buttons, sidebar user info and logout. Setup:
bundle add devise, run devise:install and devise User generators, add devise_for :users to routes. Configure Layered::Ui.current_user_method if not using :current_user. Helpers: l_ui_devise_installed?, l_ui_user_signed_in?.
- Pagy - auto-detected. Use
l_ui_pagy(@pagy) for styled pagination.
- Ransack - auto-detected. Use
l_ui_search_form and l_ui_sort_link for styled search and sortable tables.
Configuration
Layered::Ui.current_user_method = :current_member
Common issues
- Tailwind classes not generated - The host app's Tailwind build only sees classes in the host app's templates. Use
l-ui- classes (which are defined in the engine CSS) rather than raw Tailwind utilities when styling engine-provided patterns.
- Missing styles - Ensure
@import "../builds/tailwind/layered_ui"; is in app/assets/tailwind/application.css. The import resolves to a file generated by tailwindcss-rails' engine support; it is created automatically by tailwindcss:build/watch (and assets:precompile), so run a build (e.g. bin/dev) if the file is missing.
- Missing JS controllers - Ensure
import "layered_ui" is in app/javascript/application.js.
Further reference
references/HELPERS.md - full helper signatures and examples
references/CSS.md - complete CSS class catalogue
references/CONTROLLERS.md - Stimulus controller targets, actions, and usage patterns
- Live demo: https://layered-ui-rails.layered.ai