Unite Ruby on Rails brilliance. Streamline development with Tramway.
AI agents: read this first. Before writing or changing any code in a Rails app that uses Tramway, install and use the
tramway-skillskill — it is mandatory for Tramway work and is the authoritative, up-to-date playbook for Tramway's conventions (CRUD, Forms, Decorators, Navbar, Plugins, and more). Do not generate Tramway-related code from memory alone.
Tramway ships with full CRUD (index, show, create, update, destroy) out of the box. Its install generator also adds a Codex instruction that points agents to the Tramway skill for Tramway-native code generation.
Tramway is actively verified against the following Ruby and Rails versions.
| Ruby \ Rails | 8.0 | 8.1 |
|---|---|---|
| 3.2 | ✅ | ✅ |
| 3.3 | ✅ | ✅ |
| 3.4 | ✅ | ✅ |
| 4.0 | ✅ | ✅ |
Rails 7 is no longer supported. See the announcement for details.
Add this line to your application's Gemfile:
gem "tramway"Then install Tramway and its dependencies:
bundle install
bin/rails g tramway:installThe install generator adds the required gems (haml-rails, kaminari, view_component, dry-initializer, and dry-monads) to your
application's Gemfile—if they are not present—appends the Tailwind safelist configuration Tramway ships with, and injects the
Trix stylesheet and JavaScript tags into your application layout (required for rich_text_area).
Step 1
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :user,
pages: [
{ action: :index },
{ action: :show },
{ action: :create },
{ action: :update },
{ action: :destroy }
],
}
]
endStep 2
config/routes.rb
Rails.application.routes.draw do
mount Tramway::Engine, at: '/'
endStep 3
class UserDecorator < Tramway::BaseDecorator
delegate_attributes :email, :created_at
def self.index_attributes
[:id, :email, :created_at]
end
endStep 4
If you ran bin/rails g tramway:install, the install generator already copied the Tailwind safelist to
config/tailwind.config.js.
Otherwise, copy this file from the gem:
config/tailwind.config.js.
Step 5
Run tailwincss-rails compiler
bin/rails tailwindcss:buildStep 6
Run your server
bundle exec rails sStep 7
Open http://localhost:3000/users
Tramway is an entity-based framework. Entity is the class on whose objects actions be applied: index, show, create, update, and destroy. Tramway will support numerous classes as entities. For now, Entity could be only ActiveRecord::Base class.
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [ :user, :podcast, :episode ] # entities based on models User, Podcast and Episode are defined
endBy default, links to the Tramway CRUD index page are rendered in Tramway Navbar.
Tramway Entity supports several options that are used in different features.
route
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :user,
namespace: :admin
}, # `/admin/users` link in the Tramway Navbar
{
name: :episodes,
route: {
route_method: :episodes
}
}, # `/podcasts/episodes` link in the Tramway Navbar
]
endscope
By default, Tramway lists all records for an entity on the index page. You can narrow the records displayed by providing a
scope. When set, Tramway will call the named scope on the entity before rendering the index view.
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
namespace: :admin,
pages: [
{
action: :index,
scope: :active
}
]
}
]
endIn this example, the Campaign entity will display only records returned by the active scope on its index page, while all
other pages continue to show every record unless another scope is specified.
search
Search is disabled by default on index pages. Enable it per-entity by setting search: true on the :index page entry:
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
pages: [
{
action: :index,
search: true
}
]
}
]
endWhen search is enabled, a text input and a search icon button appear at the top of the index page. Tramway uses
Model.search(query) if you define it (e.g. a pg_search_scope or any custom scope named search). If not, it falls back
to Model.tramway_search(query), which searches across all string/text columns of the model using the pg_search
gem, and logs a warning. The fallback is generic and not tailored to your data structure, so it is not intended for
long-term use and may be slow or not scalable.
pg_search requires PostgreSQL's full-text search functions. If your application's database is not PostgreSQL, calling
the fallback raises Tramway::Errors::UnsupportedDatabaseAdapterError with an explanation and a suggestion to define your
own Model.search(query) scope instead.
bin/rails g tramway:install adds gem "pg_search" to your Gemfile automatically (like Tramway's other dependencies), so
run it (and bundle install) if you see the fallback raise Tramway::Errors::MissingGemError. That error also fires if
pg_search can't otherwise be loaded, and explains how to fix it or define your own Model.search(query) scope.
includes
If a decorator's index_attributes reads an association (e.g. object.comments.map(&:text)), each row triggers its own
query unless the association is eager-loaded. Set includes on the :index page entry to preload it, using the same
shape Rails' .includes accepts (a symbol, array, or nested hash):
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
namespace: :admin,
pages: [
{
action: :index,
includes: [:user, :comments]
}
]
}
]
endIn this example, Tramway calls Campaign.includes(:user, :comments) before applying scope/search, so accessing
campaign.user or campaign.comments in the decorator's index_attributes does not issue an extra query per row.
Omitting includes produces identical behavior to before this option existed.
filters
Filters are disabled by default on index pages. Enable them by listing the scope names you want selectable under
filters on the :index page entry:
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
namespace: :admin,
pages: [
{
action: :index,
filters: [:active, :archived]
}
]
}
]
endWhen filters are configured, Tramway adds a "Filters" toggle to the same row as the search box and pagination, above the index listing. Clicking it reveals a list of small buttons, one per configured filter, each showing how many records currently match it (e.g. "ACTIVE · 34"). Clicking a button navigates to the index page with that filter applied, keeping any existing search query. The currently applied filter is highlighted, and the toggle remembers whether the list was last left open or closed.
Visiting the index page with ?filter=active calls Campaign.active (the scope must already exist on the model) in
addition to any configured scope. If filter does not match one of the configured filters, Tramway raises
Tramway::Errors::InvalidFilterError explaining which filters are available. Omitting filters (the default) makes
Tramway ignore params[:filter] entirely.
show page
To render a show page for an entity, declare a :show action inside the pages array in
config/initializers/tramway.rb. Tramway will generate the route and render a table using the attributes returned by the
decorator's 'show_attributes` method.
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
pages: [
{ action: :index },
{ action: :show }
]
}
]
endclass CampaignDecorator < Tramway::BaseDecorator
def show_attributes
%i[name status starts_at]
end
endWith this configuration in place, visiting the show page displays a two-column table where the left column contains the localized attribute names and the right column renders their values.
Each entry in show_attributes can also be a Hash instead of a plain symbol, to pass options to the tramway_cell
rendering that attribute's value. The Hash requires an attribute: key (the method to display) and accepts an
options: key (a Hash forwarded as-is to tramway_cell, e.g. truncate: false):
class CampaignDecorator < Tramway::BaseDecorator
def show_attributes
[:name, { attribute: :status, options: { truncate: false } }, :starts_at]
end
endcreate page
To render a create page for an entity, declare a :create action inside the pages array in
config/initializers/tramway.rb. Tramway will generate the route and render the form fields based on your form object.
See the form_fields method for details on configuring fields.
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
pages: [
{ action: :index },
{ action: :create }
]
}
]
endupdate page
To render an update page, declare an :update action inside the pages array in config/initializers/tramway.rb.
Tramway will generate the edit route and reuse the same form partial as create. See the
form_fields method for details on configuring fields.
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
pages: [
{ action: :index },
{ action: :update }
]
}
]
endfields method
Use fields in your form class to customize which form helpers get rendered and which options are passed to them.
Each field must map to a form helper method name. When you need to pass options, use a hash where :type is the helper
method name and the remaining keys are passed as named arguments.
class UserForm < Tramway::BaseForm
properties :email, :about_me, :user_type, :score
fields email: :email,
name: :text,
about_me: {
type: :text_area,
rows: 5
},
user_type: {
type: :select,
collection: ['regular', 'user']
},
score: {
type: :number,
value: -> (object) { Score.find_by(user_id: object.id).value }
}
def score=(value)
Score.find_by(user_id: object.id).update(value:)
end
endThe configuration above renders:
<%= tramway_form_for .... do |f| %>
<%= f.email_field :email %>
<%= f.text_field :name %>
<%= f.text_area :about_me, rows: 5 %>destroy page
To render a destroy action, declare a :destroy action inside the pages array in config/initializers/tramway.rb.
Tramway will generate the destroy route and render the delete action button.
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
pages: [
{ action: :index },
{ action: :destroy }
]
}
]
endcustom page
Besides the built-in index, show, create, update, and destroy pages, an entity can declare Custom Pages:
your own controller action and view, loaded through the same routing, layout, and navbar as the rest of the entity's
pages. Use a Custom Page for anything that doesn't fit the built-in CRUD actions — a report, a bulk tool, an
"impersonate" button's target page, and so on.
Declare it by adding a page whose action is not one of the built-in ones:
Tramway.configure do |config|
config.entities = [
{
name: :user,
namespace: :admin,
pages: [
{ action: :index },
{
action: :impersonate,
member: true, # nests the route under /:id (default: false, an entity-level route)
via: :post, # HTTP method(s) the route accepts (default: :get); also accepts an array, e.g. %i[get post]
params: [:redirect_to] # extra required path segments, e.g. /admin/users/:id/impersonate/:redirect_to
}
]
}
]
endThen implement the action yourself, by Rails naming convention, in the controller Tramway would otherwise generate
for that namespace/entity (here, Admin::UsersController), inheriting from Tramway::EntitiesController so it gets
the same layout, navbar, and entity/model_class lookups as the built-in pages:
# app/controllers/admin/users_controller.rb
module Admin
class UsersController < Tramway::EntitiesController
def impersonate
@user = model_class.find(params[:id])
# ...
end
end
endAnd add its view at the conventional Rails path for that controller/action:
-# app/views/admin/users/impersonate.html.haml
= tramway_container do
%p Impersonating #{@user.email}See docs/users/tramway_custom_pages.md for how the routing/controller/view resolution works under the hood.
route_helper
To get routes Tramway generated just Tramway::Engine.
Tramway::Engine.routes.url_helpers.users_path => '/admin/users'
Tramway::Engine.routes.url_helpers.podcasts_episodes_path => '/podcasts/episodes'Tramway provides convenient decorators for your objects. NOTE: This is not the decorator pattern in its usual representation.
app/controllers/users_controller.rb
def index
# this line of code decorates the users collection with the default UserDecorator
@users = tramway_decorate User.all
endapp/decorators/user_decorator.rb
class UserDecorator < Tramway::BaseDecorator
# delegates attributes to decorated object
delegate_attributes :email, :first_name, :last_name
association :posts
# you can provide your own methods with access to decorated object attributes with the method `object`
def created_at
I18n.l object.created_at
end
# you can provide representations with ViewComponent to avoid implementing views with Rails Helpers
def posts_table
component 'table', object.posts
end
endYou can inject custom content above an entity's index table by defining an
index_header_content lambda on its decorator. The lambda receives the
collection of decorated records and can render any component you need. See
docs/users/tramway_entities.md for where exactly this content is placed on
the page.
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :project,
pages: [
{ action: :index }
]
}
]
endapp/decorators/project_decorator.rb
class ProjectDecorator < Tramway::BaseDecorator
class << self
def index_header_content
lambda do |_collection|
component "projects/index_header"
end
end
end
endapp/components/projects/index_header_component.html.haml
.mb-2
= tramway_button path: Rails.application.routes.url_helpers.new_project_path,
text: 'Create',
type: :hopeapp/components/projects/index_header_component.rb
class Projects::IndexHeaderComponent < Tramway::BaseComponent
endWith this configuration in place, the index page will render the Create
button and the header content above the table of projects.
To inject custom content above a record's details, define an
object-level show_header_content method on its decorator. The method
can return any rendered content and has full access to the decorated
object's helpers and attributes.
Some helpers—such as button_to with non-GET methods—need access to the
live request context to include CSRF tokens. Pass the Rails view_context
into your decorated objects so their render calls execute inside the
current request.
def show
@project = tramway_decorate(Project.find(params[:id])).with(view_context:)
endFor index pages, decorate each record with the current context before rendering headers or per-row components:
def index
@projects = tramway_decorate(Project.all).map { |project| project.with(view_context:) }
endPassing the context this way ensures show_header_content and
index_header_content blocks can safely call helpers that require the
session-bound authenticity token.
You can use the same method to decorate a single object either
def show
@user = tramway_decorate User.find params[:id]
endAll objects returned from tramway_decorate respond to
with(view_context:), so you can attach the current Rails view_context
when you need decorator-rendered content to use helpers that rely on the
active request (such as CSRF tokens).
def index
@users = tramway_decorate User.all
enddef index
@posts = tramway_decorate user.posts
endYou can implement a specific decorator and ask Tramway to decorate with it
def show
@user = tramway_decorate User.find(params[:id]), decorator: Users::ShowDecorator
endDecorate single association
class UserDecorator < Tramway::BaseDecorator
association :posts
end
user = tramway_decorate User.first
user.posts # => decorated collection of posts with PostDecoratorDecorate multiple associations
class UserDecorator < Tramway::BaseDecorator
associations :posts, :users
endEntity show pages can render associated records by returning association names from
the decorator's show_associations method. Each name must be an actual
Active Record association on the decorated model; Tramway raises a clear error
when the association is missing.
app/models/post.rb
class Post < ApplicationRecord
has_many :comments
endapp/decorators/post_decorator.rb
class PostDecorator < Tramway::BaseDecorator
association :comments
def show_associations
%i[comments]
end
endTramway renders a New button for a shown association when it can determine a
create path for that association. Define the associated model as a Tramway entity
with a :create page to let Tramway build that path automatically:
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :post,
pages: [
{ action: :show }
]
},
{
name: :comment,
pages: [
{ action: :create }
]
}
]
endWhen new_record_path is omitted, Tramway looks for a configured entity using
the association reflection's class name in underscore format. This means aliases
such as has_many :responses, class_name: 'Comment' use the comment entity,
not a response entity.
Generated association create paths include a redirect query parameter pointing
to the current record's configured Tramway entity show path, for example:
/comments/new?redirect=%2Fposts%2F1.
You can override the path explicitly when the associated record should be created through a custom route:
class PostDecorator < Tramway::BaseDecorator
association :comments
def show_associations
[
[
:comments,
{ new_record_path: Rails.application.routes.url_helpers.new_post_comment_path(object) }
]
]
end
endTramway still appends the redirect query parameter to custom paths. Associations
without a create route and without new_record_path still render their records,
but no creation button is displayed.
Tramway Decorator does not decorate nil objects
user = nil
UserDecorator.decorate user # => nilRead behave_as_ar section
When an entity has update or destroy pages configured, Tramway automatically
appends an Actions column to the end of the entity's index table headers,
holding the edit/destroy controls for each row.
This label is looked up through I18n (tramway.table.actions_header), so you
can rename it to your own language by overriding the key in your app's locale
file:
config/locales/en.yml
en:
tramway:
table:
actions_header: "Actions"Tramway provides convenient form objects for Rails applications. List properties you want to change and the rules in Form classes. No controllers overloading.
*app/forms/user_form.rb
class UserForm < Tramway::BaseForm
properties :email, :password, :first_name, :last_name, :phone
normalizes :email, with: ->(value) { value.strip.downcase }
endControllers without Tramway Form
app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new
if @user.save user_params
render :show
else
render :new
end
end
def update
@user = User.find params[:id]
if @user.save user_params
render :show
else
render :edit
end
end
private
def user_params
params[:user].permit(:email, :password, :first_name, :last_name, :phone)
end
endControllers with Tramway Form
app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = tramway_form User.new
if @user.submit params[:user]
render :show
else
render :new
end
end
def update
@user = tramway_form User.find params[:id]
if @user.submit params[:user]
render :show
else
render :edit
end
end
endWe also provide submit! as save! method that returns an exception in case of failed saving.
app/forms/user_updating_email_form.rb
class UserUpdatingEmailForm < Tramway::BaseForm
properties :email
endapp/controllers/updating_emails_controller.rb
def update
@user = UserUpdatingEmailForm.new User.find params[:id]
if @user.submit params[:user]
# success
else
# failure
end
endapp/forms/admin/user_form.rb
class Admin::UserForm < Tramway::BaseForm
properties :email, :password, :first_name, :last_name, :etc
endapp/controllers/admin/users_controller.rb
class Admin::UsersController < Admin::ApplicationController
def create
@user = tramway_form User.new, namespace: :admin
if @user.submit params[:user]
render :show
else
render :new
end
end
def update
@user = tramway_form User.find(params[:id]), namespace: :admin
if @user.submit params[:user]
render :show
else
render :edit
end
end
endTramway Form supports normalizes method. It's almost the same as in Rails
class UserForm < Tramway::BaseForme
properties :email, :first_name, :last_name
normalizes :email, with: ->(value) { value.strip.downcase }
normalizes :first_name, :last_name, with: ->(value) { value.strip }
endnormalizes method arguments:
*properties- collection of properties that will be normalizedwith:- a proc with a normalizationapply_on_nil- by default isfalse. WhentrueTramway Form applies normalization onnilvalues
Tramway Form supports validates method to add form-level validations before saving.
class UserForm < Tramway::BaseForm
properties :email, :role
validates :email, format: { with: /@/ }, allow_nil: true
validates :role, presence: true
endvalidates uses the standard ActiveModel/ActiveRecord validation options (for example presence: true,
format:, length:, allow_nil, allow_blank, etc.), so with: is optional unless a validator
requires it (like format).
When validations fail, submit returns false and adds errors to the form object, and submit! raises ActiveRecord::RecordInvalid.
Tramway Form supports inheritance of properties, normalizations, validations, and fields.
Example
class UserForm < TramwayForm
properties :email, :password
normalizes :email, with: ->(value) { value.strip.downcase }
validates :email, format: { with: /@/ }, allow_nil: true
fields email: :email,
password: :password
end
class AdminForm < UserForm
properties :permissions
end
AdminForm.properties # returns [:email, :password, :permissions]
AdminForm.normalizations # contains the normalization of :email
AdminForm.validators_on(:email) # includes the validation of :email
AdminForm.fields # { email: :email, password: :password }Tramway Form properties are not mapped to a model. You're able to make extended forms.
app/forms/user_form.rb
class UserForm < Tramway::BaseForm
properties :email, :full_name
# EXTENDED FIELD: full name
def full_name=(value)
object.first_name = value.split(' ').first
object.last_name = value.split(' ').last
end
endTramway Form provides assign method that allows to assign values without saving
class UsersController < ApplicationController
def update
@user = tramway_form User.new
@user.assign params[:user] # assigns values to the form object
@user.reload # restores previous values
end
endRead behave_as_ar section
When you call tramway_navbar without passing any arguments, it renders a navbar that lists buttons linking to all entities configured with a page index in config/initializers/tramway.rb.
Tramway provides DSL for rendering Tailwind Navgiation bar.
tramway_navbar title: 'Purple Magic', background: { color: :red, intensity: 500 } do |nav|
nav.left do
nav.item 'Users', '/users'
nav.item 'Podcasts', '/podcasts'
end
nav.right do
nav.item 'Sign out', '/users/sessions', method: :delete, confirm: 'Wanna quit?'
end
end<%= tramway_navbar title: 'Purple Magic', background: { color: :red, intensity: 500 } do |nav| %>
<% nav.left do %>
<%= nav.item 'Users', '/users' %>
<%= nav.item 'Podcasts', '/podcasts' %>
<% end %>
<% nav.right do %>
<%= nav.item 'Sign out', '/users/sessions', method: :delete, confirm: 'Wanna quit?' %>
<% end %>
<% end %>will render this
nav.item accepts an optional icon: option with a Font Awesome class string, rendered before the item's label:
nav.left do
nav.item 'Users', '/users', icon: 'fa fa-users'
nav.item 'Podcasts', '/podcasts', icon: 'fa fa-podcast'
endIcons are optional — items without one just render as plain text links. When the vertical sidebar is collapsed on
desktop, items rendered with an icon: stay visible and clickable as icon-only links (their text label is hidden
but still available to screen readers via the item's aria-label); items without an icon have nothing to show once
collapsed.
This helper provides several options. Here is YAML view of tramway_navbar options structure
title: String that will be added to the left side of the navbar
title_link: Link on Tramway Navbar title. Default: '/'
background:
color: Css-color. Supports all named CSS colors and HEX colors
intensity: Color intensity. Range: **100..950**. Used by Tailwind. Not supported in case of using HEX color in the background.color
with_entities: Show Tramway CRUD index page links to navbar. Default: trueWhen you use the default vertical navbar layout, it renders as a fixed left sidebar on desktop. The icon-only
sidebar toggle collapses the whole sidebar and keeps the main content offset in sync, so the page shifts between
md:pl-72 and md:pl-24 as the sidebar changes width.
The collapse/expand motion is tuned per element rather than a blanket transition-all: the sidebar width and the
main content's padding-left each transition only that one property (transition-[width] /
transition-[padding-left]) over 300ms on the ease-in-out-strong curve (cubic-bezier(0.77, 0, 0.175, 1),
registered in config/tailwind.config.js), since both are on-screen movement. The header and nav-item list fade
with transition-opacity on the ease-out-strong curve (cubic-bezier(0.23, 1, 0.32, 1)), matching that they're
entering/exiting rather than moving. The toggle chevron no longer swaps between fa-chevron-left and
fa-chevron-right — it stays a single fa-chevron-left icon that rotates 180° (transition-transform duration-200 ease-in-out-strong) to indicate state instead of snapping instantly. All of the above respect
prefers-reduced-motion via motion-reduce:duration-0 on the movement/rotation transitions; the opacity fade is
left intact since it aids comprehension without moving anything.
Tramway now ships with Font Awesome loaded through the engine, and the vertical navbar collapse control uses a
single fa-chevron-left icon for both states (rotated when collapsed).
Tramway ships a plain font-awesome.css asset for Propshaft compatibility and adds Font Awesome's font
directory to the host app asset load path, so stylesheet_link_tag "font-awesome" resolves without extra
app-side asset configuration.
On mobile, tramway_navbar always renders the same top navbar regardless of direction:. Desktop :horizontal
renders the classic top navbar, and desktop :vertical renders the collapsible sidebar.
The navbar is position: fixed (top-0 left-0 w-screen, upgraded to a md:fixed left sidebar of md:w-72 for
the vertical desktop layout) at every breakpoint, so it stays put both when the page scrolls vertically and when a
page with wide content (for example a wide table) scrolls horizontally — the navbar never drifts along with that
scroll, including on mobile where position: sticky is unreliable across browsers for horizontal offsets. It uses
w-screen (100vw) rather than w-full (100%) so its width always tracks the actual viewport instead of
whatever containing block a 100% width would otherwise resolve against — the same overflowing wide content that
makes the page scroll horizontally in the first place.
Because the navbar is taken out of the page flow, tramway_main_container always reserves pt-16 at the top to
match the fixed navbar's height on mobile (and on desktop for the :horizontal direction, where the top bar stays
fixed at every breakpoint). When tramway_navbar renders before tramway_main_container, the main container
helper keeps this in sync with direction:: for :vertical it clears that top padding at md: (md:pt-0) and
adds the md:pl-72 left sidebar offset instead; for :horizontal it keeps the top padding and adds no left
offset.
The collapsed/expanded state of the vertical sidebar persists across page navigations. The Stimulus controller
saves the state to the browser's localStorage (key tramway-navbar-expanded) whenever the toggle button is
used, and reads it back on every page load, so collapsing the sidebar on one page keeps it collapsed everywhere
else until it's expanded again. This is per-browser state: it isn't synced across devices or persisted server-side,
and if localStorage is unavailable (e.g. private browsing with storage blocked), the sidebar simply falls back
to always starting expanded.
Since the server always renders the sidebar expanded (it has no way to know the browser's stored preference) and
the Stimulus controller only applies the collapsed classes once it connects, tramway_navbar also renders a small
blocking inline <script> alongside its markup. It reads localStorage synchronously, before the browser paints,
and — if the sidebar was left collapsed — sets a data-tramway-navbar-expanded="false" attribute on <html>. A
matching @media (min-width: 768px) CSS block (rendered alongside the navbar) keys off that attribute to apply the
collapsed width, main-container padding, and faded header/menu immediately, before any JavaScript module has had a
chance to load and run. This is what prevents the sidebar from flashing expanded and then snapping to collapsed on
page load. The Stimulus controller keeps that <html> attribute in sync afterwards (on load and on every toggle),
so this pre-paint styling and the controller's own classes never fight each other.
NOTE: tramway_navbar method called without arguments and block of code will render only Tramway CRUD links on the left.
In case you want to hide entity links you can pass with_entities: false.
The default :vertical navbar now renders as a fixed left sidebar on desktop screens. If your page content uses a full-width layout, wrap it with tramway_main_container class: 'md:pl-72' so the sidebar has room.
<% if current_user.present? %>
<%= tramway_navbar title: 'WaiWai' do |nav| %>
<% nav.left do %>
<%= nav.item 'Board', admin_board_path %>
<% end %>
<% end %>
<% else %>
<%= tramway_navbar title: 'WaiWai', with_entities: false %>
<% end %>Tramway navbar provides left and right methods that puts items to left and right part of navbar.
Item in navigation is rendered li a inside navbar ul tag on the left or right sides. nav.item uses the same approach as link_to method with syntax sugar.
tramway_navbar title: 'Purple Magic' do |nav|
nav.left do
nav.item 'Users', '/users'
# NOTE you can achieve the same with
nav.item '/users' do
'Users'
end
# NOTE nav.item supports turbo-method and turbo-confirm attributes
nav.item 'Delete user', '/users/destroy', method: :delete, confirm: 'Are you sure?'
# will render this
# <li>
# <a data-turbo-method="delete" data-turbo-confirm="Are you sure?" href="/users/sign_out" class="text-white hover:bg-red-300 px-4 py-2 rounded">
# Sign out
# </a>
# </li>
end
endtramway_flash renders the dark shadcn-style flash component that Tramway uses in its layouts. Pass the flash text and type,
and the helper will resolve the proper dark semantic accent (for example :success -> green, :warning -> orange). Tramway
does not render a separate light flash theme. You can also provide custom HTML options directly (e.g., class:, data:) and
they will be merged into the flash container.
-# Haml example
= tramway_flash text: flash[:notice], type: :hope
= tramway_flash text: 'Double check your data', type: :greed, class: 'mt-2', data: { turbo: 'false' }<%# ERB example %>
<%= tramway_flash text: flash[:alert], type: :rage %>
<%= tramway_flash text: 'Saved!', type: :will, data: { controller: 'dismissible' } %>Use the type argument is compatible to Lantern Color Palette or provide a color: keyword to set
the semantic accent explicitly.
tramway_tooltip renders a dark tooltip around block content with a default minimum width of min-w-40 and maximum width of max-w-sm. Pass text: for the tooltip body and event: to
choose when it appears. Supported events are :hover and :onclick; hover is the default.
= tramway_tooltip text: 'Shown on hover' do
%span Help
= tramway_tooltip text: 'Shown after click', event: :onclick do
%button More info<%= tramway_tooltip text: 'Shown on hover' do %>
<span>Help</span>
<% end %>
<%= tramway_tooltip text: 'Shown after click', event: :onclick do %>
<button>More info</button>
<% end %>tramway_chat renders the chat experience bundled with Tramway. Provide a chat ID, a list of message hashes, and the URL
that receives new messages. Each message must include an :id and a :type (either :sent or :received). Additional
message fields like text, data, or sent_at are forwarded to tramway/chats/message_component.
Message text preserves normal word wrapping and only breaks oversized words or links when needed to keep content inside the
message bubble.
Use send_messages_enabled: to control whether users can send new messages from the rendered form. It defaults to true.
When set to false, the text field is disabled and the waiting placeholder is shown.
-# Haml example
- messages = [
- { id: 1, type: :received, text: 'Hello 👋' },
- { id: 2, type: :sent, text: 'Hi there!' }
- ]
= tramway_chat chat_id: 'support-chat',
messages: messages,
message_form: @message_form,
send_message_path: chat_messages_path,
send_messages_enabled: @chat.open?<%# ERB example %>
<% messages = [{ id: 1, type: :received, text: 'Hello 👋' }] %>
<%= tramway_chat chat_id: 'support-chat',
messages: messages,
message_form: @message_form,
send_message_path: chat_messages_path,
send_messages_enabled: @chat.open? %>If you do not want to render the message form, pass message_form: nil. When the form is present, send_message_path is
required and the helper will generate the correct POST form.
To append a message to an already-rendered tramway_chat stream, use tramway_chat_append_message.
The method is mixed into controllers and ActiveRecord models by Tramway and expects:
chat_id:— the same value used intramway_chat chat_id:type:— only:sentor:received(raisesArgumentErrorotherwise)text:— message contentsent_at:— message timestamp
tramway_chat_append_message(
chat_id: 'support-chat',
type: :received,
text: 'We got your request',
sent_at: Time.current
)It broadcasts with target: 'messages' and renders the tramway/chats/message partial so the message appears in the live chat.
To prepend a message to the same stream, use tramway_chat_prepend_message. It accepts the same arguments and validation rules,
but inserts the rendered tramway/chats/message partial at the beginning of the messages target instead of the end.
tramway_chat_prepend_message(
chat_id: 'support-chat',
type: :received,
text: 'Earlier message from history',
sent_at: 5.minutes.ago
)To append multiple messages at once, use tramway_chat_append_messages. The method expects:
chat_id:— the same value used intramway_chat chat_id:messages:— an array of hashes, where each hash includestype:,text:, andsent_at:
Each message type: must be :sent or :received or the helper raises ArgumentError. It broadcasts with target: 'messages' and renders the tramway/chats/messages partial so the full collection appears in the live chat.
tramway_chat_append_messages(
chat_id: 'support-chat',
messages: [
{ type: :received, text: 'First update', sent_at: 2.minutes.ago },
{ type: :sent, text: 'Thanks, checking now', sent_at: 1.minute.ago }
]
)To prepend multiple messages, use tramway_chat_prepend_messages. It accepts the same chat_id: and messages: arguments
as tramway_chat_append_messages, validates each type:, and prepends the rendered tramway/chats/messages partial to the
beginning of the messages target.
tramway_chat_prepend_messages(
chat_id: 'support-chat',
messages: [
{ type: :received, text: 'Older history item', sent_at: 10.minutes.ago },
{ type: :sent, text: 'Reply from earlier', sent_at: 9.minutes.ago }
]
)Tramway provides a responsive, tailwind-styled table with light and dark themes. Use the tramway_table, tramway_row, and
tramway_cell helpers to build tables with readable ERB templates while still leveraging the underlying ViewComponent
implementations.
<%= tramway_table do %>
<%= tramway_header headers: ['Column 1', 'Column 2'] %>
<%= tramway_row do %>
<%= tramway_cell do %>
Something
<% end %>
<%= tramway_cell do %>
Another
<% end %>
<% end %>
<% end %>tramway_table accepts an optional size: argument plus the same options hash as Tramway::TableComponent. Supported
sizes are :small, :medium, and :large. The default is :medium, and it renders exactly like the current table
implementation. The options hash is forwarded as HTML attributes, so you can pass things like id, data attributes, or
additional classes. The table itself carries the default dark background (bg-zinc-950), so you can override it directly
with a utility like class: '!bg-red-500'. If you do not supply your own width utility (e.g. a class that starts with w-),
the component automatically appends w-full to keep the table responsive.
tramway_row, tramway_header, and tramway_cell automatically follow the active table size, so a single size: on
tramway_table controls the whole table.
Use the optional href: argument on tramway_row to turn an entire row into a link. Linked rows keep their transparent
background and gain pointer and hover styles (cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700) to indicate
interactivity.
<%= tramway_table size: :large, class: 'max-w-3xl border border-gray-200', data: { controller: 'table' } do %>
<%= tramway_header headers: ['Name', 'Email'] %>
<%= tramway_row href: user_path(user) do %>
<%= tramway_cell do %>
<%= user.name %>
<% end %>
<%= tramway_cell do %>
<%= user.email %>
<% end %>
<% end %>
<% end %>Cell content that does not fit its column is truncated with an ellipsis. This is a pure CSS behavior (truncate +
min-w-0 on each cell) — no JavaScript is involved, and no configuration is needed to enable it.
Pass truncate: false to tramway_cell to opt a specific cell out of this behavior and let its content wrap/overflow
normally:
<%= tramway_cell truncate: false do %>
<%= user.bio %>
<% end %>On narrow screens all columns always render, and the table scrolls horizontally within itself (the page never stretches past the viewport width) so every column stays reachable. See docs/users/tramway_table.md for the full details of the mobile scroll behavior.
Use tramway_grid when you want dashboard-style layouts with cards that span multiple rows and columns while keeping the
existing Tramway dark zinc styling. The grid accepts rows: and columns:, and it also accepts outline: true when you
want to show separators between cells. Those separators are drawn in the gap between grid cells, so they stay centered
when cards have content. Cards use size: [rows, columns], with responsive spans on desktop and stacked cards on smaller
screens. Each grid cell is rendered as a real 20em x 20em box, and the grid always builds the exact rows x columns
matrix even when the slot is empty. Cards participate in the same grid, so size: [1, 1] occupies exactly one cell.
= tramway_grid rows: 8, columns: 8, outline: true do
= tramway_card size: [1, 2] do
.text-sm.font-medium.text-zinc-400 Revenue
.mt-2.text-3xl.font-semibold.text-zinc-50 $128,240
= tramway_card size: [2, 3] do
.text-sm.font-medium.text-zinc-400 Activity
.mt-4.space-y-2
.flex.items-center.justify-between
%span.text-zinc-300 Orders
%span.text-zinc-50 48
.flex.items-center.justify-between
%span.text-zinc-300 Refunds
%span.text-zinc-50 3tramway_cell also forwards any HTML options to the underlying cell wrapper. Use this when you need per-cell classes,
data attributes, ids, or other attributes:
<%= tramway_cell class: 'whitespace-nowrap', data: { turbo: false } do %>
<%= user.name %>
<% end %>tramway_header forwards HTML options to the header wrapper as well. That makes it easy to add ids, classes, or data
attributes to the header row:
<%= tramway_header headers: ['Name', 'Email'], class: 'sticky top-0', data: { controller: 'table-header' } %>When you render a header you can either pass the headers: array, as in the examples above, or render custom header content in
the block. tramway_header uses the length of the headers array to build the grid if the array is present.
If you omit the array and provide custom content, pass the columns: argument so the component knows how many grid columns to
generate.
<%= tramway_header columns: 4 do %>
<%= tramway_cell do %>
Custom header cell
<% end %>
<%= tramway_cell do %>
Another header cell
<% end %>
<!-- ... -->
<% end %>With this approach you control the header layout while still benefiting from the default Tailwind grid classes that the header component applies.
Tramway ships with helpers for common UI patterns built on top of Tailwind components.
tramway_button helper is designed for developers who don't want to think about what type of button must be used now. It can render 3 types of buttons: a (links), button, form with button.
You can set a type to render explicitly:
tramway_button tag: :a, text: 'Link' #=> <a class=BUTTON_CLASSES>Link</a>
tramway_button tag: :button, text: 'Link' #=> <button class=BUTTON_CLASSES>Link</button>
tramway_button tag: :form, text: 'Link' #=> <form><button class=BUTTON_CLASSES>Link</button></form>OR tramway_button choose the most appropriate button type by the arguments received.
path
Example 1: rendering link
<%= tramway_button path: '/projects', text: 'Projects' %>
#=> <a href='/projects' class="...">Projects</a>Example 2: rendering form
<%= tramway_button path: '/projects/1', text: 'Destroy', method: :delete %>
#> <form action="/projects/1" method="post">
#> ....
#> <button class: "..." type="submit">
#> Delete
#> </button>Example 3: rendering button
<%= tramway_button path: '/projects/1', text: 'Edit', tag: :button %>
#> <button onclick="window.location.href='/projects/1' class="...">Edit</button>
All additional keyword arguments are forwarded to the underlying component as HTML attributes.
You can also pass HTML attributes for the generated <form> separately via form_options: while keeping button-specific
attributes in options::
<%= tramway_button path: user_path(user), text: 'Create',
form_options: { data: { turbo: false } },
options: { data: { controller: 'submit-once' } } %>The type option maps semantic intent to Lantern Color Palette.
Use size: to select one of the built-in button sizes: :small, :medium, or :large. The default is :medium.
<%= tramway_button path: '/projects', text: 'Projects', size: :small %>Use tooltip: to show Tramway's tooltip from the rendered button. The tooltip accepts the same event: values as
tramway_tooltip: :hover or :onclick.
<%= tramway_button path: '/projects', text: 'Projects', tooltip: { text: 'Open projects', event: :hover } %>-
tramway_badgerenders a dark shadcn-style Tailwind badge with the providedtext. Pass a semantictype(for example,:successor:danger) to use the built-in color mappings, or supply a custom Tailwind color family withcolor:. When you opt into a custom color, ensure the corresponding accent utilities are available in your Tailwind safelist. -
tramway_container(backed byTramway::Containers::NarrowComponent, used for example by the entities index page) now sizes itself withw-max min-w-fullinstead ofw-fullon both its outer box and inner flex wrapper. A plain block box withw-fullnever grows past 100% of its parent even when a child (e.g. a wide table forcing horizontal scroll) overflows it, so the container's background stops at the viewport edge while the overflowing content keeps going.w-maxsizes the box to its content's intrinsic (max-content) width, andmin-w-fullkeeps a 100% floor so normal, non-overflowing pages still span the full width — together the container always covers the whole page, even when something inside it stretches wider than the viewport.<%= tramway_badge text: 'Active', type: :success %>
-
tramway_back_buttonrenders a standardized "Back" link.<%= tramway_back_button %>
-
tramway_containerwraps content in a responsive, narrow layout container. Pass anidif you need to target the container with JavaScript or CSS.<%= tramway_container id: 'user-settings' do %> <h2 class="text-xl font-semibold">Settings</h2> <p class="mt-2 text-gray-600">Update your preferences below.</p> <% end %>
Tramway uses Tailwind by default. All UI helpers are implemented with ViewComponent.
Tramway provides tramway_form_for helper that renders Tailwind-styled forms by default. Form inputs use hardcoded
dark shadcn-style classes; Tramway does not render a separate light form theme.
Checkboxes render with the same dark border and focus highlight treatment as other inputs, then use the light primary checked state.
Checkbox controls, checkmarks, and vertically centered label line heights scale together with the form-level size: option.
<%= tramway_form_for @user do |f| %>
<%= f.text_field :text %>
<%= f.email_field :email %>
<%= f.password_field :password %>
<%= f.select :role, [:admin, :user] %>
<%= f.date_field :birth_date %>
<%= f.datetime_field :confirmed_at %>
<%= f.rich_text_area :bio %>
<%= f.tramway_select :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
<%= f.file_field :file %>
<%= f.submit 'Create User' %>
<% end %>will render this
Use size: to control the input sizing (:small, :medium, or :large). The default is :medium, and supported inputs
rendered within the form will use the same size value.
Date and datetime fields open the browser's native picker when clicking anywhere in the input, not only the picker icon.
Use hint: on any field to render muted helper text below the input. The hint option is handled by Tramway and is not
forwarded as an HTML attribute.
<%= tramway_form_for @user, size: :large do |f| %>
<%= f.text_field :text, hint: 'This name is visible to teammates.' %>
<%= f.submit 'Create User' %>
<% end %>Use horizontal: true to render a horizontal form layout.
<%= tramway_form_for @user, horizontal: true do |f| %>
<%= f.text_field :text %>
<%= f.submit 'Create User' %>
<% end %>Available form helpers:
- text_field
- email_field
- number_field
- text_area
- password_field
- file_field
- check_box
- checkbox (alias for check_box)
- select
- date_field
- datetime_field
- time_field
- rich_text_area (Action Text/Trix editor with Tramway dark form classes)
- tramway_select (Stimulus-based)
- submit
Autocomplete select example:
<%= tramway_form_for @user do |f| %>
<%= f.select :role, [:admin, :user], autocomplete: true %>
<% end %>autocomplete: true renders an autocomplete-enabled select. It can be combined with multiple: true to get a
searchable multi-select:
<%= tramway_form_for @user do |f| %>
<%= f.select :departments, [['Engineering', 'engineering'], ['Sales', 'sales']], autocomplete: true, multiple: true %>
<% end %>With both options set, the search input stays visible after each selection so you can keep filtering and picking more options; selected options still show as removable tags.
When you use multiple: true, make sure any preselected values still exist in the collection you render. Unknown
values are ignored by the widget so the select can still boot safely.
Examples
- Sign In Form for
deviseauthentication
app/views/devise/sessions/new.html.erb
<%= tramway_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4') } do |f| %>
<%= component 'forms/errors', record: resource %>
<%= f.text_field :email, placeholder: 'Your email' %>
<%= f.password_field :password, placeholder: 'Your password' %>
<%= f.submit 'Sign In' %>
<% end %>- Sign In Form for Rails authorization
app/views/sessions/new.html.erb
<%= form_with url: login_path, scope: :session, local: true, builder: Tramway::Form::Builder do |form| %>
<%= form.email_field :email %>
<%= form.password_field :password %>
<%= form.submit 'Log in' %>
<% end %>rich_text_area (and its alias rich_textarea) renders a Trix rich text editor
with Tramway's dark-theme Tailwind styling. Trix is bundled with Rails and does not require an additional gem.
Before using rich_text_area, the Trix stylesheet and JavaScript must be present in your application layout.
The tramway:install generator adds them automatically. To add them manually:
-# app/views/layouts/application.html.haml
= stylesheet_link_tag "trix", "data-turbo-track": "reload"
= javascript_include_tag "trix", "data-turbo-track": "reload", defer: true<%# app/views/layouts/application.html.erb %>
<%= stylesheet_link_tag "trix", "data-turbo-track": "reload" %>
<%= javascript_include_tag "trix", "data-turbo-track": "reload", defer: true %>Basic usage:
<%= tramway_form_for @post do |f| %>
<%= f.rich_text_area :body %>
<% end %>Custom label and extra CSS class:
<%= tramway_form_for @post do |f| %>
<%= f.rich_text_area :body, label: 'Post body', class: 'min-h-60' %>
<% end %>Suppress the label:
<%= tramway_form_for @post do |f| %>
<%= f.rich_text_area :body, label: false %>
<% end %>Pass data attributes for Stimulus controllers:
<%= tramway_form_for @post do |f| %>
<%= f.rich_text_area :body, data: { controller: 'mentions' } %>
<% end %>size: is a Tramway-level option and is not forwarded to the <trix-editor> element.
tramway_form_for provides Tailwind-styled Stimulus-based custom inputs.
In case you want to use the tailwind-styled Tramway select this way
<%= tramway_form_for @user do |f| %>
<%= f.tramway_select :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
<%# ... %>
<% end %>you should add the Tramway Select Stimulus controller to your application.
Example for importmap-rails config
config/importmap.rb
pin '@tramway/tramway', to: 'tramway/tramway.js'app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import { TramwaySelect } from "@tramway/tramway" // importing TramwaySelect controller class
eagerLoadControllersFrom("controllers", application)
application.register('tramway-select', TramwaySelect) // register TramwaySelect controller class as `tramway-select` stimulus controllerIn case you need to use Stimulus change action with Tramway Select
<%= tramway_form_for @user do |f| %>
<%= f.tramway_select :role, data: { action: 'change->user-form#updateForm' } %>
<% end %>Remote form example:
<%= tramway_form_for @user, remote: true do |f| %>
<%= f.text_field :name %>
<%= f.email_field :email %>
<% end %>With remote: true, Tramway submits the form on each input change via inline JavaScript; no additional controller setup is required.
Tramway uses Tailwind by default. It has tailwind-styled pagination for kaminari.
app/views/users/index.html.erb
<%= paginate @users %> <%# it will render tailwind-styled pagination buttons by default %>Pagination buttons looks like this
On small screens (below Tailwind's sm breakpoint), the "First" and "Last" buttons are hidden and
only the current page number stays visible next to the Prev/Next arrows, so the control doesn't
overflow a phone-width viewport. Pair it with Kaminari's own
page_entries_info helper to show a compact entry
count on mobile instead of the full set of page links:
= paginate @users
%p.sm:hidden
= page_entries_info @usersTramway Decorator and Tramway Form support behave_as_ar method. It allows to use update and destroy methods with decorated and form objects.
Tramway Decorator and Tramway Form have public object method. It allows to access ActiveRecord object itself.
user_1 = tramway_decorate User.first
user_1.object #=> returns pure user object
user_2 = tramway_form User.first
user_2.object #=> returns pure user objectIn case you wanna use a custom layout:
- Create a controller
- Set the layout there
- Set this controller as
application_controllerin Tramway initializer - Reload your server
Example
app/controllers/admin/application_controller.rb
class Admin::ApplicationController < ApplicationController
layout 'admin/application'
endconfig/initializers/tramway.rb
Tramway.configure do |config|
config.application_controller = 'Admin::ApplicationController'
endPlugins add optional, ready-made dashboards to your app, mounted and styled with Tramway. Enable one by name and, optionally, configure it:
Tramway.configure do |config|
config.plugins = [:solid_queue]
config.plugins.solid_queue = {
path: '/jobs'
}
endEnabling a plugin also adds a navbar entry for it automatically (see Tramway Navbar).
The :solid_queue plugin is a dashboard for inspecting and managing SolidQueue
jobs. It requires the solid_queue gem in your Gemfile — if it's missing, Tramway raises a clear error explaining
how to fix it instead of failing deep inside a controller.
It gives you:
- A paginated, filterable jobs list (by status, queue, class name, or free-text search by class name/active job ID/numeric ID), with per-status counts.
- A job detail page, including failure details (exception class, message, backtrace) for failed jobs.
- Retry, discard and destroy for a single job, or in bulk for a selection of jobs — or "Destroy all" to destroy
every job matching the currently active filters at once. Bulk actions and "Destroy all" run in the background
(you'll see a flash message confirming the action was queued, not completed instantly) on their own dedicated
worker queue, which
tramway:installsets up for you. - A queues list with size/running/latency/throughput, and pause/resume/clear actions per queue. "Size" only counts jobs waiting to run — "Running" shows jobs currently executing on that queue, so a queue processing one big job (e.g. a large "Destroy all") doesn't look idle just because nothing is waiting behind it.
- A recurring tasks list with the ability to enqueue a task immediately.
- Jobs, queues, and recurring tasks lists (and the job detail page) refresh themselves in the background every few seconds, so job status changes show up without reloading the page.
See docs/users/tramway_solid_queue_plugin.md for more on what "discard" does and other SolidQueue-specific behavior.
| Type | Color |
|---|---|
default, life |
Gray |
primary, hope |
Blue |
secondary |
Zinc |
success, will |
Green |
warning, greed |
Orange |
danger, rage |
Red |
love |
Violet |
compassion |
Indigo |
fear |
Yellow |
- Tramway on Rails
- Tramway is the way to deal with little things for Rails developers
- Delegating ActiveRecord methods to decorators in Rails
- Behave as ActiveRecord. Why do we want objects to be AR lookalikes?
- Decorating associations in Rails with Tramway
- Easy-to-use Tailwind-styled multi-select built with Stimulus
- Lantern Color Palette
lefthook is pinned in .tool-versions so mise can resolve the shim used by make install.
make install
The gem is available as open source under the terms of the MIT License.