An OKLCH-themed Rails component library, built to WCAG 2.2 AA and held to AAA wherever a component library controls the outcome. It is the design system for modelrails_base, built on ViewComponent.
Lineage & acknowledgements —
modelrails_uiis a hardened fork of alec-c4/view_primitives, which is itself inspired by shadcn/ui (and its Svelte port shadcn-svelte). Deep thanks to those projects. This fork re-themes every component onto modelrails_base's AAA OKLCH token system, adopts the app's superior implementations where they exist (e.g. the native-<dialog>modal), adds form-builder/accessibility integration, fixes upstream generator bugs, and proves AAA contrast in its own test suite. MIT licensed (seeLICENSE.txt).
Components are copied into your app via a generator — you own the files. This fork is
a dev-only scaffolding gem: it generates components + the token CSS into your app,
which you commit. Production carries no runtime dependency on modelrails_ui (only
view_component).
- AAA where a component library can deliver it, and tested. Enhanced contrast (7:1 text, computed from the shipped tokens in light and dark), focus appearance, reduced motion and 44px targets each have a test that fails the build if they regress. Most of the rest of AAA is content your pages own, such as reading level or captions. docs/accessibility.md goes through all 31 AAA criteria: what checks each one, what is yours, and the known gaps.
- Ships an OKLCH design system tuned for AAA contrast. The install generator emits the
full token system (primitives → semantic tokens,
@theme inline, class-based dark mode,.btn-*/bg-hue-*). - Self-contained app. The generated
ApplicationComponentinlines thecnhelper and an inflection initializer is written into your app, so nothing references the gem at runtime. - Integrates with Rails. Form components accept first-class a11y params
(
required/invalid/describedby→ ARIA) and are drivable by a customFormBuilderor standalone. TheDialogis a native<dialog>with focus-trap/restore.
See MODELRAILS_STATUS.md for the per-component maturity record (solid / pre-release /
broken / kept-app-native) and CHANGELOG.md for the fork changes.
- Ruby >= 3.2 · Rails >= 8.0 · ViewComponent >= 4.0 · Tailwind CSS 4
# Gemfile — scaffolding tool, not shipped to production
group :development do
gem "modelrails_ui", git: "https://github.com/dschmura/modelrails_ui.git", tag: "v0.16.0"
endrails g modelrails_ui:install # tokens + self-contained ApplicationComponent + UI inflection
rails g modelrails_ui:add button input dialog # copy components into app/components/uiCommit the generated files. To update later: bump the tag, re-run the generators, review the diff.
<%= render UI::ButtonComponent.new("Save", variant: :primary) %>
<%= render UI::DialogComponent.new(title: "Confirm") do |d| %>
<% d.with_trigger { render UI::ButtonComponent.new("Open") } %>
<p>Body</p>
<% end %>Rather than changing call sites, point your existing integration seams at the components — so adoption is zero-churn (this is how modelrails_base adopts it):
- Forms: your
FormBuilderrendersUI::Input/Textarea/FileInputinternally; views keep usingf.text_field/f.text_area/f.file_fieldunchanged. - Modal: a thin
shared/_modalpartial rendersUI::DialogComponent(wrapper: false). - Avatar: an
avatar_forhelper rendersUI::AvatarComponent. - Buttons: use the component for new code; existing
.btn-*CSS keeps working.
rails g modelrails_ui:list shows available + installed components.
For a navigable, shareable component explorer — live, styled, interactive previews developers can poke at:
rails g modelrails_ui:lookbook # preview layout + initializer + ViewComponent::Preview classesThen add gem "lookbook" to your :development group, mount it in config/routes.rb
(mount Lookbook::Engine, at: "/lookbook" if Rails.env.development?), and visit /lookbook.
The generated preview layout loads your app's compiled Tailwind and importmap, so previews render
with real tokens and working Stimulus (the dialog actually opens). Previews land in
spec/components/previews/ui/ — edit or extend them freely. Dev-only; nothing ships to production.
rails g modelrails_ui:agent_rulesWrites .modelrails_ui/agent-rules.md (gem-owned design-system rules, overwritten on
re-run) and seeds .modelrails_ui/house-rules.md (your editable host-policy defaults), then
adds a marker-delimited @-import to your CLAUDE.md/AGENTS.md. It reports — never
rewrites — directives that conflict with the design system. Re-run after a gem bump to refresh
the rules; your house-rules edits are preserved.
Which WCAG criteria the gem holds, and how each is checked, is in
docs/accessibility.md. The gem's suite (rake test) runs three
lanes — see docs/testing.md for what each one can and, importantly,
cannot prove:
test:structural— fast, no Rails: reads the.rb.tttemplates as text and asserts structure (test/test_components.rband friends).test:render— real rendering: boots a minimal Rails app + ViewComponent and renders a component, asserting actual HTML/ARIA. AAA contrast is guaranteed bytest/test_aaa_contrast.rb(token ratios), so a render test that asserts a component uses the semantic token classes inherits AAA.test:system— a real Chrome: proves what only happens once JavaScript runs (open/close, roving focus, ARIA a controller keeps in sync, runtime-minted ids).
Two things the browser lane cannot prove. It serves no compiled stylesheet, so colour contrast and top-layer promotion are both out of reach there —
axeruns withcolor-contrastdisabled, andtop_layer.jscorrectly declines to promote a panel that never computes toposition: fixed. Both are proven inmodelrails_baseagainst a real app with built CSS. Do not inject styles to make them fire locally; the assertion would prove the injected style. Details in docs/testing.md.
To add a render test for a new component, create
test/render/<name>_render_test.rb:
# test/render/<name>_render_test.rb
require "render_test_helper"
load_component "<name>", "<name>_component.rb.tt"
class <Name>RenderTest < ViewComponent::TestCase
def test_renders_with_aaa_tokens
render_inline(UI::<Name>Component.new(...))
assert_selector "..." # tag + structure
assert_selector ".bg-interactive" # AAA semantic token actually rendered
end
endReplace <name> with the snake_case component name and <Name> with PascalCase
(e.g. button / Button). This render lane is the verification basis for the
component-hardening program
(see docs/design/2026-06-03-component-hardening-program-design.md).
Rendering/ARIA is also verified by integration specs in the consuming app (a real view context), where each adopted component is asserted for correct markup and ARIA.
None outstanding for component generation. form_field, qr_code, input_otp and
embed were listed here as broken; all four generate, render and are covered by
test/render/. The component-hardening program proved all 82.