Keystone UI is a reusable UI component system for Rails applications built on the
view_component gem. It provides stable, authoritative UI primitives that avoid ERB noise,
prevent UI drift, and enable safe mass updates.
- All UI lives in ViewComponents (no partials).
- Components are Ruby objects with explicit keyword arguments.
- Helpers are thin render wrappers with no logic or conditionals.
- Styling uses Tailwind CSS utility classes applied directly in components.
- Rails 7+
- tailwindcss-rails v4+
- Add the gem to your Gemfile:
gem "keystone_ui"-
Run
bundle install. -
Run the install generator:
rails generate keystone:installThe generator handles setup automatically:
- If
app/assets/tailwind/application.cssdoes not exist, the generator creates it with the Tailwind import and Keystone source import. No need to runtailwindcss:installfirst. - If it already exists, the generator injects the Keystone source import after the existing Tailwind import line.
- It wires the Stimulus controllers by appending
registerControllers(application)toapp/javascript/controllers/index.js, so interactive components (accordion, modal, stat card info, …) work out of the box. If that file isn't present, the generator prints the one line to add manually.
At boot time, the engine initializer writes keystone_source.css with @source directives pointing to the gem's component files so Tailwind scans them automatically. The engine also adds the gem's controllers to your importmap automatically.
- The generator commits only an
@import "./keystone_source.css"line — no machine-specific paths in git. - On every app boot (dev server,
assets:precompile, CI), the engine writeskeystone_source.csswith@sourceand theme@importdirectives. - Tailwind's JIT scanner finds all component classes automatically.
- When keystone updates with new components, they're picked up on the next build with no action required.
Re-run the generator. It removes legacy @import and @source inline(...) lines
automatically:
rails generate keystone:installKeystone UI components use two semantic color scales — accent and surface — defined as CSS custom properties. Components reference these via Tailwind classes like bg-accent-500, text-accent-600, bg-surface-100, etc. This means your entire UI updates when you change the color values — no need to touch component code.
The gem ships a theme.css that sets default values (imported automatically by the engine initializer):
| Scale | Default palette | Used for |
|---|---|---|
accent |
Blue (#3b82f6 at 500) |
Buttons, links, focus rings, active states, badges |
surface |
Zinc (#71717a at 500) |
Backgrounds, borders, text on dark mode surfaces |
Each scale provides shades 50-950, matching Tailwind's standard shade range.
Override the CSS custom properties in your app's application.css (after the Tailwind import):
@import "tailwindcss";
@import "./keystone_source.css";
@theme {
/* Override accent to indigo */
--color-accent-50: #eef2ff;
--color-accent-100: #e0e7ff;
--color-accent-200: #c7d2fe;
--color-accent-300: #a5b4fc;
--color-accent-400: #818cf8;
--color-accent-500: #6366f1;
--color-accent-600: #4f46e5;
--color-accent-700: #4338ca;
--color-accent-800: #3730a3;
--color-accent-900: #312e81;
--color-accent-950: #1e1b4e;
}You only need to override the shades you use. All Keystone components will pick up the new colors automatically.
If you're building a gem or engine that uses keystone_ui, do not set theme colors in your gem. The host app owns the theme. Your gem just uses the accent/surface Tailwind classes and they'll inherit whatever the host app has configured.
In your gem's test/dummy app, set up colors the same way a host app would:
- Add
keystone_uito your gemspec as a dependency - Run
rails generate keystone:installintest/dummy - The default blue/zinc theme applies automatically — no extra config needed
- To test with a custom theme, override the variables in
test/dummy/app/assets/tailwind/application.css:
@import "tailwindcss";
@import "./keystone_source.css";
@theme {
--color-accent-500: #6366f1;
/* ... */
}Key principle: gems use bg-accent-500, text-surface-700, etc. in their views. The host app decides what those colors actually are — either statically in CSS, or dynamically with keystone_colors for per-user theming.
For dynamic, per-user color customization (e.g. letting users pick their own accent color), see the keystone_colors gem, which generates the CSS custom properties from user preferences at runtime.
Use the helpers in ERB. Consuming apps should not instantiate components directly.
<%= ui_card(
title: "Revenue",
summary: "$42,300 this month",
link: reports_path
) %>
<%= ui_button(
label: "Create invoice",
href: new_invoice_path,
variant: :primary
) %>
<%= ui_data_table(
items: @products,
columns: [
{ name: "Name" },
{ quantity: "Quantity" },
{ price: "Price" }
],
empty_message: "No products found."
) do |table| %>
<% table.link(:name) { |item| product_path(item) } %>
<% end %>Renders a card layout with a title, summary, and a single call-to-action link.
Required props
title:(String)summary:(String)link:(String or URL)
Optional props
cta:(String, default"Read more")edge_to_edge:(Boolean, defaultfalse) — whentrue, removes horizontal border-radius and side borders on mobile, restoring them at thesmbreakpoint. Useful for cards that span the full viewport width on small screens.
Renders a deterministic button or link. If href is present, an <a> tag is rendered.
Otherwise, a <button> tag is rendered with type="button".
Required props
label:(String)
Optional props
href:(String or URL)variant:(:primary | :secondary | :danger, default:primary)size:(:sm | :md | :lg, default:md)
Renders a responsive data table. Accepts a collection of items (ActiveRecord objects, Structs, or hashes) and column definitions that map lookup keys to header labels.
Required props
items:(Array) — collection of AR objects, Structs, or hashescolumns:(Array of Hashes orColumnobjects) — each hash maps a lookup key to a header label, e.g.{ name: "Name" }. UseKeystone::Ui::Columnfor per-column options.
Optional props
empty_message:(String) — message displayed whenitemsis emptysort:(Symbol/String) — current sort column keysort_direction:(Symbol) —:ascor:descsort_url:(Lambda) —(col, dir) → urlfor generating sort linkshidden_columns:(Array) — column keys to hide (only affectshideablecolumns)
Column options
Keystone::Ui::Column.new(key, header_text, mobile_hidden: false, sortable: false, hideable: false)
| Option | Default | Description |
|---|---|---|
mobile_hidden: |
false |
hide on small screens (hidden sm:table-cell) |
sortable: |
false |
render header as clickable sort link |
hideable: |
false |
allow hiding via hidden_columns: / column picker |
Mobile-hidden columns
Use Keystone::Ui::Column objects to hide columns on mobile. Columns with mobile_hidden: true receive hidden sm:table-cell classes, hiding them on small screens and showing them from the sm breakpoint up. Columns are visible on mobile by default.
<%= ui_data_table(
items: @products,
columns: [
Keystone::Ui::Column.new(:name, "Name"),
Keystone::Ui::Column.new(:quantity, "Quantity", mobile_hidden: true),
Keystone::Ui::Column.new(:price, "Price")
]
) %>Sortable columns
Mark columns as sortable: true and pass sort:, sort_direction:, and sort_url: to render clickable header links with sort arrows. Sort links use data-turbo-action="replace" for clean Turbo navigation. Toggle logic: active asc → desc, active desc → asc, inactive → asc.
<%
columns = [
Keystone::Ui::Column.new(:name, "Name", sortable: true),
Keystone::Ui::Column.new(:quantity, "Quantity", mobile_hidden: true),
Keystone::Ui::Column.new(:price, "Price", sortable: true)
]
%>
<%= ui_data_table(
items: @products,
columns: columns,
sort: params[:sort],
sort_direction: params[:direction],
sort_url: ->(col, dir) { products_path(sort: col, direction: dir) }
) %>Hidden columns
Mark columns as hideable: true and pass hidden_columns: to filter them out server-side. Non-hideable columns are always shown even if listed in hidden_columns:.
<%= ui_data_table(
items: @products,
columns: columns,
hidden_columns: current_user.hidden_columns_for(:products)
) %>Linkable cells
Register links via table.link(:column_key) in the block. The block receives the current item and must return a URL string. The cell's value is wrapped in an <a> tag.
<%= ui_data_table(
items: @products,
columns: [
{ name: "Name" },
{ quantity: "Quantity" },
{ price: "Price" }
]
) do |table| %>
<% table.link(:name) { |item| product_path(item) } %>
<% end %>Actions column
Pass a block to add a trailing "Actions" column. The block receives the component instance; call actions on it with a sub-block that receives each item.
<%= ui_data_table(
items: @products,
columns: [
{ name: "Name" },
{ status: "Status" }
]
) do |table| %>
<% table.link(:name) { |item| product_path(item) } %>
<% table.actions do |item| %>
<%= link_to "Edit", edit_product_path(item) %>
<%= link_to "Delete", product_path(item), data: { turbo_method: :delete } %>
<% end %>
<% end %>When an actions column is present, position-based styling classes shift automatically — the last data column receives middle styling and the actions column receives last styling.
Renders a "Columns" dropdown button with checkboxes for showing/hiding hideable columns. Pair with ui_data_table's hidden_columns: param.
Required props
columns:(Array ofColumnobjects) — same array passed toui_data_table
Optional props
hidden_columns:(Array) — currently hidden column keyssave_url:(String) — PATCH endpoint to persist preferences; omit for no persistence
On checkbox change, the Stimulus column-picker controller PATCHes { hidden_columns: [...] } as JSON to save_url, then reloads via Turbo.visit.
<%= ui_column_picker(
columns: columns,
hidden_columns: @hidden_columns,
save_url: table_preferences_path("products")
) %>Wraps page content with consistent max-width and horizontal padding.
Optional props
max_width:(:sm | :md | :lg | :xl | :full, default:full) — constrains content width. Values map tomax-w-2xl,max-w-4xl,max-w-6xl,max-w-7xl, or no constraint.padding:(:standard | :none, default:standard) — adds responsive horizontal padding (px-4 sm:px-6 lg:px-8).
<%= ui_page(max_width: :lg) do %>
<!-- page content -->
<% end %>Groups related content with an optional header (title, subtitle, action) and vertical spacing.
Optional props
title:(String) — section headingsubtitle:(String) — secondary text below the titleaction:— slot for a trailing action (e.g. a button)spacing:(:sm | :md | :lg, default:md) — top margin between sections
<%= ui_section(title: "Products", subtitle: "All active items", spacing: :lg) do %>
<!-- section content -->
<% end %>Renders a CSS grid with responsive column counts and configurable gap sizes.
Optional props
cols:(Hash, default{ default: 1 }) — maps breakpoints to column counts (1-12). Keys::default,:sm,:md,:lg.gap:(:sm | :md | :lg | :xl, default:md) — uniform gap sizegap_x:(Symbol) — horizontal gap (overridesgap:)gap_y:(Symbol) — vertical gap (overridesgap:)
<%= ui_grid(cols: { default: 1, sm: 2, lg: 4 }, gap: :lg) do %>
<!-- grid items -->
<% end %>Renders a bordered, rounded container with padding and optional shadow.
Optional props
padding:(:sm | :md | :lg, default:md)radius:(:md | :lg | :xl, default:lg)shadow:(Boolean, defaulttrue)
<%= ui_panel(padding: :lg) do %>
<!-- panel content -->
<% end %>Renders a clickable card that wraps its content in an <a> tag with hover styling.
Required props
href:(String or URL)
Optional props
padding:(:sm | :md | :lg, default:md)shadow:(Boolean, defaulttrue)
<%= ui_card_link(href: product_path(@product)) do %>
<h3>Product name</h3>
<p>Product description</p>
<% end %>Wraps content in a <form> tag with proper method handling. For non-GET/POST methods (patch, put, delete), it renders a hidden _method input following Rails conventions. Supports multipart for file uploads.
Required props
action:(String) — form action URL
Optional props
method:(:get | :post | :patch | :put | :delete, default:post) — HTTP method. Non-native methods use a hidden_methodfield.multipart:(Boolean, defaultfalse) — setsenctype="multipart/form-data"for file uploadsdata:(Hash) — data attributes for the form element
<%= ui_form(action: items_path, method: :post) do %>
<%= ui_form_field(attribute: :name, required: true) %>
<%= ui_button(label: "Save", type: :submit) %>
<% end %>
<%= ui_form(action: item_path(@item), method: :patch, multipart: true) do %>
<%= ui_form_field(attribute: :name) %>
<%= ui_file_upload(name: "item[photo]", accept: "image/*", hint: "Max 5MB") %>
<%= ui_button(label: "Save", type: :submit) %>
<% end %>Renders a styled file upload with a clickable drop zone. Clicking anywhere on the zone opens the native file picker. Supports drag-and-drop — the zone highlights with accent colors on drag-over. Displays the selected file name(s) after pick or drop. Uses the file-upload Stimulus controller.
Required props
name:(String) — input name attribute
Optional props
label:(String, default"Choose file") — label text above the drop zoneaccept:(String) — accepted file types (e.g."image/*",".pdf,.doc")multiple:(Boolean, defaultfalse) — allow multiple file selectionhint:(String) — help text below the drop zone (e.g. file size limits)
<%= ui_file_upload(name: "avatar", accept: "image/*", hint: "PNG or JPG, max 5MB") %>
<%= ui_file_upload(name: "documents[]", multiple: true, label: "Upload documents", hint: "PDF, DOC up to 10MB each") %>Wraps a label, input, hint, and error message in a consistent layout. Infers label text from the attribute name when not explicitly provided.
Required props
attribute:(Symbol) — the form attribute name
Optional props
label:(String) — explicit label text (inferred fromattribute:if omitted)type:(:text | :number | :email | :password | :textarea, default:text)required:(Boolean, defaultfalse) — shows a red asterisk after the labelhint:(String) — help text below the inputplaceholder:(String)min:/max:(for number inputs)
<%= ui_form_field(
attribute: :name,
label: "List Name",
required: true,
hint: "Enter a descriptive name"
) %>Renders a standalone <input> element with consistent styling.
Required props
name:(String)
Optional props
type:(:text | :number | :email | :password, default:text)value:(String/Number)placeholder:(String)disabled:(Boolean, defaultfalse)min:/max:/step:(for number type)
<%= ui_input(name: "search", placeholder: "Search...") %>
<%= ui_input(name: "quantity", type: :number, value: 1, min: 1) %>Renders a multi-line <textarea> element with consistent styling.
Required props
name:(String)
Optional props
value:(String)rows:(Integer, default3)placeholder:(String)disabled:(Boolean, defaultfalse)
<%= ui_textarea(name: "notes", rows: 5, placeholder: "Add notes...") %>Renders a page title area with an optional subtitle and action slot. On small screens the title stacks above actions; on wider screens they sit side-by-side.
Required props
title:(String) — the page heading
Optional props
subtitle:(String) — secondary text below the title
Block API — register an action slot via header.action:
<%= ui_page_header(title: "Products", subtitle: "Manage your catalog") do |header| %>
<% header.action do %>
<%= ui_button(label: "New Product", href: new_product_path) %>
<% end %>
<% end %>Renders a styled alert/flash message with type variants, optional title, and dismissible button.
Required props
message:(String) — the alert message
Optional props
type:(:info | :success | :warning | :error, default:info) — determines background/text colortitle:(String) — bold title above the messagedismissible:(Boolean, defaultfalse) — shows a dismiss button whentrue
<%= ui_alert(message: "Changes saved successfully.", type: :success) %>
<%= ui_alert(message: "Could not save record.", type: :error, title: "Error", dismissible: true) %>Renders a styled <select> dropdown.
Required props
name:(String)
Optional props
options:(Array of[label, value]pairs, default[])selected:(String) — pre-selected valueinclude_blank:(String) — blank option labeldisabled:(Boolean, defaultfalse)
<%= ui_select(
name: "status",
options: [["Active", "active"], ["Inactive", "inactive"]],
selected: "active",
include_blank: "Select status..."
) %>Renders an inline status badge.
Required props
label:(String)
Optional props
variant:(:neutral | :success | :danger | :warning | :info, default:neutral)
<%= ui_badge(label: "Active", variant: :success) %>
<%= ui_badge(label: "Expired", variant: :danger) %>Renders a metric card for dashboards.
Required props
label:(String)value:(String/Number)
Optional props
variant:(:neutral | :success | :danger | :warning | :info, default:neutral)suffix:(String) — unit label after the value
<%= ui_stat_card(label: "Revenue", value: "$42,300", variant: :success, suffix: "/mo") %>Renders a card wrapper for chart content with a title and configurable height.
Required props
title:(String)
Optional props
height:(:sm | :md | :lg, default:md) — maps toh-48,h-64,h-96
<%= ui_chart_card(title: "Monthly Revenue", height: :lg) do %>
<!-- chart content -->
<% end %>Renders a button that copies text to the clipboard.
Required props
text:(String) — the text to copy
Optional props
label:(String, default"Copy")success_message:(String, default"Copied!")error_message:(String, default"Failed!")
<%= ui_copy_button(text: "https://example.com/invite/abc123") %>Renders a modal dialog with title, close button, and backdrop.
Required props
title:(String)
Optional props
size:(:sm | :md | :lg | :xl, default:md)
<%= ui_modal(title: "Confirm Delete", size: :sm) do %>
<p>This action cannot be undone.</p>
<% end %>Renders collapsible question/answer items.
Optional props
items:(Array of Hashes) — each with:questionand:answerkeys
<%= ui_accordion(items: [
{ question: "What is Keystone?", answer: "A UI component library for Rails." },
{ question: "How do I install it?", answer: "Add the gem and run the generator." }
]) %>Renders a tab bar with active state indicator. Uses Stimulus tab-switcher controller.
Required props
tabs:(Array of Strings) — tab labels
<%= ui_tab_switcher(tabs: ["Overview", "Details", "History"]) do %>
<!-- tab panel content -->
<% end %>Renders a toggleable card option (radio-like selection).
Required props
name:(String) — input namevalue:(String) — input value
Optional props
selected:(Boolean, defaultfalse)input_data:(Hash) — data attributes for the hidden inputlabel_data:(Hash) — data attributes for the label
<%= ui_option_card(name: "theme", value: "dark", selected: true) do %>
Dark Mode
<% end %>Renders a large hero section for landing pages.
Required props
title:(String)
Optional props
subtitle:(String)badge:(String) — small badge text above the titlelayout:(:split | :centered, default:split)
<%= ui_hero(title: "Build faster with Keystone", subtitle: "UI components for Rails", badge: "New") do |hero| %>
<% hero.with_aside do %>
<!-- image or illustration -->
<% end %>
<% end %>Renders a grid of feature cards with icons.
Required props
title:(String)features:(Array of Hashes) — each with:icon,:title,:description
Optional props
subtitle:(String)
<%= ui_feature_grid(
title: "Why Keystone?",
subtitle: "Built for Rails developers",
features: [
{ icon: "🚀", title: "Fast", description: "No build step required." },
{ icon: "🎨", title: "Themeable", description: "CSS custom properties." }
]
) %>Renders a call-to-action banner with title, subtitle, and action buttons.
Required props
title:(String)
Optional props
subtitle:(String)
<%= ui_cta_banner(title: "Ready to get started?", subtitle: "Try Keystone today.") do %>
<%= ui_button(label: "Get Started", href: signup_path) %>
<% end %>Renders an HSV color picker with swatch preview. Uses Stimulus color-picker controller.
Required props
name:(String) — form input name
Optional props
value:(String, default"#000000") — initial hex colorlabel:(String)
<%= ui_color_picker(name: "accent_color", value: "#3b82f6", label: "Accent") %>Renders the top-level navigation bar with slots for desktop and mobile sections. Sticky by default.
Optional props
sticky:(Boolean, defaulttrue)
Slots: logo, desktop_links, desktop_right, mobile_left, mobile_center, mobile_right
<%= ui_navbar do |nav| %>
<% nav.with_logo do %>
<%= link_to "MyApp", root_path %>
<% end %>
<% nav.with_desktop_links do %>
<%= ui_nav_item(label: "Dashboard", href: root_path, active: true) %>
<% end %>
<% end %>Renders a single nav link within the navbar.
Required props
label:(String)href:(String)
Optional props
active:(Boolean, defaultfalse)
<%= ui_nav_item(label: "Dashboard", href: "/", active: current_page?(root_path)) %>Renders a dropdown menu within the navbar. Uses Stimulus dropdown controller.
Required props
title:(String)area:(Symbol/String)
Optional props
active:(Boolean, defaultfalse)
<%= ui_nav_dropdown(title: "Settings", area: :settings, active: false) do %>
<%= link_to "Profile", profile_path %>
<%= link_to "Billing", billing_path %>
<% end %>Renders a mobile bottom tab bar. Hidden on desktop (lg:hidden).
No props. Wrap ui_bottom_nav_item calls inside.
<%= ui_bottom_nav do %>
<%= ui_bottom_nav_item(label: "Home", href: "/", icon: "<svg>…</svg>", active: true) %>
<%= ui_bottom_nav_item(label: "Search", href: "/search", icon: "<svg>…</svg>") %>
<% end %>Renders a single bottom nav tab.
Required props
label:(String)href:(String)icon:(String) — raw SVG string
Optional props
active:(Boolean, defaultfalse)
<%= ui_bottom_nav_item(label: "Home", href: "/", icon: "<svg>…</svg>", active: true) %>Renders a mobile header with back link, centered title, and optional subtitle. Hidden on lg: screens.
Required props
title:(String)back_url:(String)
Optional props
subtitle:(String)
<%= ui_mobile_header(title: "Edit Product", back_url: products_path) %>Renders an ellipsis dropdown for mobile action menus. Hidden on lg: screens. Uses Stimulus dropdown controller.
No props. Pass action links as block content.
<%= ui_mobile_actions do %>
<%= link_to "Edit", edit_product_path(@product) %>
<%= link_to "Delete", product_path(@product), data: { turbo_method: :delete } %>
<% end %>Wraps a form page with title and back navigation. Sets content_for signals so the navbar can render mobile header context.
Required props
title:(String)back_url:(String)
Optional props
subtitle:(String)
<%= ui_form_page(title: "New Product", back_url: products_path) %>Wraps a show/detail page with title and back navigation. Sets content_for signals so the navbar can render mobile header context.
Required props
title:(String)back_url:(String)
Optional props
subtitle:(String)
<%= ui_show_page(title: @product.name, back_url: products_path, subtitle: "Details") %>Renders a settings row link with label and chevron icon.
Required props
label:(String)href:(String)
<%= ui_settings_link(label: "Account", href: account_settings_path) %>Cutting a new version to RubyGems.org is documented in RELEASING.md.