shadcn_view_component
shadcn/ui ported to Rails ViewComponent,
1:1 — the same part names, the same variants, the same Tailwind classes and
data-slot attributes. Radix UI's behaviour is reimplemented with Stimulus, so
there is no React and no npm dependency.
Two specs hold the port to upstream. spec/parity_spec.rb reads the TSX
vendored in vendor/shadcn/ui and fails if a class React emits is missing from
the Ruby; spec/snapshot_spec.rb diffs the rendered HTML of every preview
against a committed golden file. See
what the specs prove for the honest version.
Installation
# Gemfile
gem "shadcn_view_component"
bin/rails generate shadcn_view_component:install
The generator adds the imports to your Tailwind entrypoint and the Stimulus
registration to application.js. Those three CSS lines are the reason it
exists: each one names a path into the gem, and that path differs between a
system gem, bundle config set path, and a path: or git: source.
They are filesystem paths, not asset-pipeline names. tailwindcss-rails
runs the Tailwind CLI with -i and -o and no load path, so the CLI resolves
a bare @import "shadcn.css" the way Node would — beside the file, then in
node_modules — and a Rails app has neither. It stops the build with
Can't resolve 'shadcn.css'.
Doing it by hand, with PATH from bundle show shadcn_view_component:
/* app/assets/tailwind/application.css */
@import "tailwindcss";
@import "PATH/app/assets/stylesheets/shadcn.css";
@import "PATH/app/assets/stylesheets/shadcn-themes.css"; /* optional: the swappable palettes */
@source "PATH/app/components";
shadcn-themes.css has to come after shadcn.css: .theme-* and :root have
the same specificity, so source order is what decides.
Prefer a relative path where you can. An absolute one is correct on the
machine that wrote it and wrong on every other, and the CSS is built on all of
them. With bundle config set path vendor/bundle — what CI and most containers
do — the gem lives inside the application and the three lines can be written
relative to the entrypoint, which then holds everywhere. That is what the
generator writes when it can.
// app/javascript/controllers/index.js
import { registerShadcnControllers } from "shadcn"
registerShadcnControllers(application)
That file, and not app/javascript/application.js. controllers/index.js
is where importmap-rails puts import { application } from "controllers/application", so the registration has an instance to attach to.
application.js imports "controllers" for the side effect and defines no
such binding: register there and the browser says ReferenceError: application is not defined, nothing is registered, and every dialog, select and menu is
inert — with the CSS working, so it looks installed. Starting a second
Application.start() instead is worse in a subtler way: two Stimulus instances
then fight over every element they both claim.
If your app has no Stimulus yet, start one and pass it that same way.
The engine contributes its own importmap pins, so shadcn resolves with no
further configuration when the host app uses importmap-rails.
Configuration
# config/application.rb
config.shadcn_view_component.cache_size = 50_000
tailwind-merge keeps an LRU of merged class strings. Its own default is 500,
which a page built from these components can exhaust — and a Rails render is the
worst case for an LRU, since it cycles the same keys every request, so once the
working set exceeds the cache the hit rate collapses and stays collapsed. This
gem defaults to 10,000; raise it if you generate many distinct computed classes
(w-[#{percent}%] down a long table, say).
Usage
Every React part is one component class, under Shadcn:::
| React | Ruby |
|---|---|
<Button variant="outline" size="sm"> |
Shadcn::Button::Component.new(variant: :outline, size: :sm) |
<CardHeader> |
Shadcn::Card::Header::Component |
<SelectItem value="apple"> |
Shadcn::Select::Item::Component.new(value: "apple") |
Parents also expose slots, so the common case stays short:
<%= render(Shadcn::Card::Component.new(class: "w-full max-w-sm")) do |card| %>
<% card.with_header do |header| %>
<% header.with_title { "Login to your account" } %>
<% header.with_description { "Enter your email below to login." } %>
<% end %>
<% card.with_card_content do %>
<%= render(Shadcn::Input::Component.new(type: "email", placeholder: "m@example.com")) %>
<% end %>
<% card.with_footer do %>
<%= render(Shadcn::Button::Component.new(class: "w-full")) { "Login" } %>
<% end %>
<% end %>
The explicit form is always available, and is what you want when order matters or when parts are interleaved with labels and separators:
<%= render(Shadcn::Card::Component.new) do %>
<%= render(Shadcn::Card::Header::Component.new) do %>…<% end %>
<% end %>
Slot content is rendered before block content, so don't mix the two inside one parent unless you mean that ordering.
User-visible strings go through I18n under shadcn_view_component.*, with
shadcn's English as the default — override any key in your own locale files.
Anything you pass beyond the documented arguments lands on the element, exactly
like React's {...props}, and wins over what the component set itself:
<%= render(Shadcn::Button::Component.new(type: "submit", disabled: true, data: { turbo: false })) { "Save" } %>
as: is the port of shadcn's asChild:
<%= render(Shadcn::Button::Component.new(as: :a, href: "/login", variant: :link)) { "Log in" } %>
Rails forms
shadcn_form_with is form_with with a builder that wires the Field family
to a model. Ids and names come from Rails; the error text, aria-invalid,
aria-describedby and Field's data-invalid come from ActiveModel::Errors.
<%= shadcn_form_with model: @user do |f| %>
<%= f.shadcn_input_field :email, label: "Email",
description: "We never share it.",
type: "email" %>
<%= f.shadcn_field :plan, label: "Plan" do %>
<%= f.shadcn_select :plan, [["Free", "free"], ["Pro", "pro"]] %>
<% end %>
<%= f.shadcn_submit "Create account" %>
<% end %>
shadcn_field is the wrapper — label, control, description, errors — and takes
any control in its block. shadcn_<control>_field is the shorthand for the
common case of exactly one.
| Control | Notes |
|---|---|
shadcn_input |
|
shadcn_textarea |
|
shadcn_native_select |
a real <select>: browser validation, autofill, native keyboard behaviour |
shadcn_select |
the styled listbox; submits through a hidden input, so required will not stop the form |
shadcn_checkbox, shadcn_switch |
boolean attributes |
shadcn_radio_group |
each option labelled and wired by id |
shadcn_submit |
Prefer shadcn_native_select unless you specifically want the styled listbox —
it is the one control here that a browser understands.
form_with(..., builder: ShadcnViewComponent::FormBuilder) does the same thing
if you would rather be explicit, and works with fields_for too.
A select you can filter
searchable: true puts a search field at the top of the open panel and narrows
the options as you type — case-insensitively, on a substring, so err finds
Blueberry. It works on the component and through the form builder:
<%= render(Shadcn::Select::Component.new(name: "country", searchable: true)) do |s| %>
<% s.with_trigger { |t| t.with_value(placeholder: "Select a country") } %>
<% s.with_select_content do %>
<%= render(Shadcn::Select::Item::Component.new(value: "it")) { "Italy" } %>
<% end %>
<% end %>
<%= f.shadcn_select :country, choices, searchable: true %>
Two things to know before reaching for it.
The filter is client-side, over the options already on the page. For a list
long enough that you would not render it all, listen for input on
[data-slot=select-input-wrapper] input and swap the options through a Turbo
Frame — the gem takes no position on that and ships no server mode.
This one is not a port. Every other component here reproduces a shadcn
component; no Radix-based shadcn select has a filter, so this one takes its
shape from the React Aria variant and is otherwise the gem's own. What that
means for you: its look is not guaranteed to match a future upstream, and
.claude/docs/decisions/01-architecture.md
records where it deliberately differs.
How the mapping works
| shadcn (React) | this gem |
|---|---|
cva(base, { variants, defaultVariants }) |
ViewComponentContrib::StyleVariants — the style { base {} variants {} defaults {} } DSL |
cn(...) (clsx + tailwind-merge) |
the tailwind_merge gem, wired in as the style postprocessor, so caller classes win conflicts |
data-slot="card-header" |
slot_name :"card-header" |
{...props} |
**attributes, splatted through Rails' tag builder |
asChild |
as: |
| Radix primitives | Stimulus controllers under shadcn--*, keeping the same data-state, aria-* and --radix-* custom properties in step with what the user does |
lucide-react icons |
Shadcn::Icon::Component, with the lucide SVGs inlined |
The ARIA is the component's, not the controller's. Whatever a part needs to
be what it is — its role, its aria-haspopup, a tabindex, the
aria-expanded it starts closed with — is rendered by the Ruby, so the markup
is right before any JavaScript runs and a turbo:morph cannot undo it. The
controllers only write what changes as the user acts — aria-expanded on open,
aria-activedescendant as the cursor moves, aria-controls once it knows
which element to point at. Four of them used to re-set the static half on
connect, and that was deliberately removed (commit 4e88573): two places to
write one attribute is two places to drift.
The consequence, if you write your own markup: data-controller and a
data-shadcn--*-target are not enough. Hang them on a bare <div> and you
get a div — no role, no aria-haspopup, nothing for a screen reader to
announce, and no error to tell you. Render the component, or copy every
attribute it emits — bin/console prints them:
render Shadcn::Select::Trigger::Component.new
# => <button data-slot="select-trigger" type="button" role="combobox"
# aria-expanded="false" aria-autocomplete="none" …>
21 lucide icons are bundled — exactly the ones the ported components render,
out of lucide's ~1,500, and a spec fails if that stops being exactly true in
either direction. Their drawings are not typed into Ruby: they are the files lucide publishes,
vendored in the repository under vendor/lucide/icons, and rake icons:build
turns them into the registry the component reads. Only that registry ships in
the gem — the SVGs are a build-time source, like the upstream TSX.
To add your own, put SVG files in a directory and point the registry at it
from an initializer — that is where cache_size lives too, and the reason both
go through ShadcnViewComponent::IconRegistry is that nothing autoloadable
resolves there, Shadcn:: included:
# config/initializers/shadcn_view_component.rb
ShadcnViewComponent::IconRegistry.load_directory(Rails.root.join("app/assets/icons"))
The file's basename is the icon's name, and only what the <svg> element
contains is kept — the outer element is the component's, so lucide's own
attributes and yours never end up arguing. No asset pipeline is involved: the
files are read once, by you, when you call this. A single drawing can still be
registered by hand:
# config/initializers/shadcn_view_component.rb
ShadcnViewComponent::IconRegistry.register("star", %(<path d="M12 2 15 9l7 .5-5 4 1 7-6-3z"/>))
<%= render Shadcn::Icon::Component.new("star", class: "size-4") %>
Registering a name the gem already bundles replaces it — register("check", …)
changes the tick in every checkbox, select and dropdown item — so what ships is
a set of defaults, not a fixed set.
Shadcn::Icon.register / .registered delegate to the same registry and read
more naturally from a view or another component — anywhere autoloading has
already run, which is everywhere except an initializer.
An unknown name raises where Rails.env.local? is true — development and
test, where a typo can still be fixed — and renders nothing everywhere else,
staging included: the gem cannot know every icon a host will ever pass it,
and a missing one is not worth a 500.
Components live in sidecar directories following the
Evil Martians layout:
app/components/shadcn/<family>/[<part>/]{component.rb,preview.rb,previews/*.html.erb}.
Everything is namespaced under Shadcn:: rather than sitting at the top level,
because names like Card, Table, Field and Select would otherwise collide
with a host application's own models.
Theming
Theming works exactly as shadcn documents it: semantic CSS variables under
:root and .dark, mapped into Tailwind utilities by @theme inline. Override
--primary and every bg-primary in the app follows. The full token table is
in the shadcn theming docs; this gem ships
the same tokens and the same --radius scale.
On top of that there are two independent switchable axes, ported from what runs on ui.shadcn.com:
| Axis | Values | Applied as |
|---|---|---|
| mode | light, dark, system |
.dark on <html> |
| theme | one of 24 palettes | theme-<name> on <body> |
Wiring it up
<%# app/views/layouts/application.html.erb %>
<html>
<head>
<%= shadcn_theme_script_tag %>
</head>
<body class="<%= shadcn_theme_class %>">
<%= render(Shadcn::ModeToggle::Component.new) %>
<%= render(Shadcn::ThemeSelector::Component.new(value: shadcn_theme_name)) %>
shadcn_theme_script_tag is the piece that matters: it runs before the first
paint, reads the stored preference and sets .dark and data-shadcn-theme on
<html> — which is why there is no flash of the wrong palette. It is the
equivalent of what next-themes injects.
The preference is stored in localStorage (as upstream) and mirrored into a
cookie, so shadcn_theme_class and shadcn_mode can render the right thing
server-side on the very first byte.
The switchers
| Component | Upstream | What it is |
|---|---|---|
Shadcn::ModeToggle::Component |
examples/mode-toggle.tsx |
The documented dropdown: Light / Dark / System |
Shadcn::ModeSwitcher::Component |
components/mode-switcher.tsx |
The single button in shadcn's own header, straight light↔dark |
Shadcn::ThemeSelector::Component |
components/theme-selector.tsx |
A select over the palettes |
Driving it yourself is a matter of one Stimulus action inside a
shadcn--theme controller:
<div data-controller="shadcn--theme">
<button data-action="shadcn--theme#toggle">Flip the mode</button>
<button data-mode="dark" data-action="click->shadcn--theme#setMode">Dark</button>
<button data-value="zinc" data-action="click->shadcn--theme#setTheme">Zinc</button>
</div>
Or from your own JavaScript:
import { setMode, setTheme, resolvedMode } from "shadcn/theme"
The palettes
Seven base greys — neutral, stone, zinc, mauve, olive, mist, taupe — and seventeen accents: amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow.
The base greys carry a complete token set. The accents are overlays: they
redefine --primary and the chart colours and let the rest fall through to
:root, which is why dropping one onto <body> recolours an app without
restating the neutrals.
Palettes also scope, so you can theme one region of a page:
<div class="theme-blue">…</div>
ShadcnViewComponent::Themes is the registry behind all of this
(ALL, BASE_COLORS, ACCENTS, find). Both the registry and
shadcn-themes.css are generated from vendor/shadcn/themes.json — run
rake themes:build after refreshing it.
A few rules resist an ordinary !important
[data-slot][hidden], [data-slot][data-exiting] and the two
animate-accordion-* reduced-motion overrides in shadcn.css are
!important inside a cascade layer, which beats an !important of your own
at any specificity. An inline style attribute gets past it, and so does a
@layer declared earlier than this stylesheet in your document.
Components
Theming — mode-toggle, mode-switcher, theme-selector
Presentational — button, badge, card, alert, avatar, separator, skeleton, spinner, kbd, aspect-ratio, progress, table, breadcrumb, pagination, label, input, textarea, native-select, field, empty, item, button-group
Form controls — checkbox, radio-group, switch, toggle, toggle-group, input-group, input-otp
Interactive — accordion, collapsible, tabs, dialog, alert-dialog, sheet, dropdown-menu (including submenus), context-menu, menubar, popover, tooltip, hover-card, select, scroll-area, navigation-menu, slider, sidebar, drawer, carousel
AI chat — message, bubble, attachment, marker, message-scroller. The first four are markup and variants with no behaviour of their own; the scroller follows its own live end, holds the view still when older history loads above, and is the one component here reimplemented from a package shadcn publishes rather than from Radix.
These are one of the two places hand-written CSS was unavoidable: shimmer,
scroll-fade-x, scroll-fade-b, scrollbar-none, scrollbar-thin and
scrollbar-gutter-stable are shadcn's own utilities rather than Tailwind's, and
are reproduced at the end of shadcn.css. The drawer is the other.
Drawer — dragged down or thrown, it closes; dragged up it gives a little and
comes back. Upstream builds this on vaul rather than Radix, and vaul is a Radix
dialog with a drag on top, so the open/close half is the dialog's here too and
only the gesture is new. Four of vaul's features are deliberately not ported —
snap points, scaling the page behind the drawer, its iOS position: fixed
workaround and nested drawers. Its stylesheet is the second piece of
hand-written CSS: drawer.tsx renders no entrance animation and no
touch-action, and both are load-bearing.
Reading direction — write dir="rtl" on any ancestor and the components
that navigate with arrow keys follow it. shadcn ships a DirectionProvider for
this; there is none here, because the browser already resolves dir before a
Stimulus controller runs.
Forms — shadcn_form_with and f.shadcn_input_field, f.shadcn_select,
f.shadcn_switch and the rest. shadcn's Form component is react-hook-form's
per-field state given five wrappers; there is no such state on a server, and
what the wrappers do with it is what Rails' FormBuilder already does with a
model. So that family is a FormBuilder here rather than components, built over
field. It emits field-* slots where upstream's form emits form-*, errors
come from ActiveModel::Errors after a round trip rather than as you type, and
all of an attribute's messages are shown rather than the first.
Toaster — notifications, stacked one behind another and fanned out under
the pointer. shadcn's is a forty-line wrapper around sonner and renders no
markup of its own, so this one is not a port: it keeps sonner's measurements and
its stacking, and adds the two ways a Rails app actually raises a notification —
a flash rendered with the page, and a turbo_stream.append onto the list.
Calendar — a month is a <table role="grid">, built in Ruby with Date and
I18n rather than by react-day-picker: a third of that package is locale data
and another fifth is other calendar systems, both of which a Rails app already
has. It is the one component here that renders correctly with no JavaScript at
all; the controller adds the nav, the keyboard and the selection, and takes its
month names and its idea of today from the server rather than from the
browser's locale and clock.
Chart — chart.tsx draws nothing: it is a container that publishes
--color-<key> per series, plus the contents of a tooltip and a legend that
recharts fills in. That frame is ported 1:1 and the shapes are drawn here, as
SVG, from the server. The pie is the first — pass a Hash of key to number, which
is what group(:x).sum(:y) already returns. Bars, lines and areas are not drawn
yet.
Resizable — panels a pointer or the arrow keys can resize. Upstream wraps
react-resizable-panels; here a panel is a share of a flex container, so the
layout is the browser's and the controller only moves two numbers. The handle is
a role="separator" with the package's own keyboard: five points an arrow, all
the way on Home and End.
Command — the palette. Upstream wraps cmdk; here the items are
server-rendered and the controller filters, ranks and walks them. The ranking
matters enough that cmdk's own fuzzy scorer is ported rather than replaced
with a substring match: type gp and Group Policy comes before Groups.
keywords: are searched and never shown.
Combobox — a field that filters a list and keeps the caret. It is the one
component shadcn writes against Base UI rather than Radix, so this family alone
emits data-open and reads --anchor-width; the port publishes those names
here and nowhere else. Single selection is complete; the chips markup is there
and adding one is not wired yet.
Every component in the registry is now ported, adapted or decided against with a reason — see the per-component notes.
What is and is not verified
Worth being precise about, because "1:1" invites more trust than any test here earns.
| Spec | Catches |
|---|---|
parity_spec.rb |
a class upstream emits that the port dropped or mistyped. Per family, not per part — swap two variants' bodies and it stays green |
snapshot_spec.rb |
anything that changes rendered HTML: wrong part, wrong variant, attribute drift, extra classes |
stimulus_contract_spec.rb |
a controller action, target or value a component names but the JavaScript does not define |
system/ |
the behaviour, in a real browser: open/close, keyboard navigation, focus trapping, positioning, persistence, Turbo Drive and morph refreshes |
system/accessibility_spec.rb |
axe over every preview, in both palettes, at rest and with each layer open |
form_builder_spec.rb, theming_spec.rb |
the Rails form wiring, the generated palettes and the switchers |
The system specs drive headless Chrome against the gallery, so they exercise the
compiled Tailwind and the actual Stimulus controllers — popper.js, the layer
stack in dismiss.js and the focus trap in focus.js included.
Two things remain unverified:
-
Parity runs one way. When upstream removes a class, the port keeps it and nothing fails. Read the TSX diff when you re-sync.
-
Accessibility is audited by axe, not by a person. Every preview is checked against WCAG 2.1 AA, at rest and with its layer open, in the light palette and the dark one. axe catches names, roles, required parents and contrast; it does not replace a screen reader, and nothing here has been through one.
The colour scheme the audit runs in is pinned, and that is not housekeeping: headless Chrome follows the desktop it runs on, so the suite spent this project's life auditing whichever palette the author was sitting in front of. The first run on a Linux CI runner audited the other one and found eleven real contrast violations.
One thing to know if you have to meet AA
shadcn's light palette puts text-muted-foreground on bg-muted at 4.34:1,
where WCAG AA wants 4.5:1 — its own tokens, muted: oklch(0.97 0 0) against
muted-foreground: oklch(0.556 0 0), and its own class string on the avatar
fallback (avatar.tsx:49). The same pair measures 5.85:1 in the dark palette.
text-destructive on bg-destructive/10, which the attachment's error state
and the destructive bubble use, comes to 4.00:1.
This port renders them unchanged, because upstream wins on markup and altering them would put classes in your bundle that upstream does not emit. If you need AA, override the token in your own CSS — one line, and it reaches every component at once:
:root { --muted-foreground: oklch(0.52 0 0); } /* measured: 5.04:1 on --muted */
That is the smallest step off upstream's 0.556 that clears AA — measured in
Chrome by painting both colours and computing the WCAG ratio, the same
arithmetic axe does.
Known differences from the React DOM
Four deliberate ones, all documented at the point where they happen:
-
Context-only roots render an element. Radix's
Dialog.Root,Popover.Root,Select.Rootand friends render no DOM at all. Stimulus needs something to attach to, so those roots emit adisplay: contentswrapper. It has no box and no effect on layout, and it gives shadcn'sdata-slot="dialog"— which Radix silently drops — somewhere to live. -
Nothing is portalled to
document.body. Radix moves overlays and floating content onto the body. Here they stay inside the component, wrapped in the samedata-slot="*-portal"/data-radix-popper-content-wrapperelements, because moving them out of the controller's element would unbind the Stimulus actions on the close buttons and menu items.Staying put means a stacking context above the component could bury it —
position: fixedescapes overflow clipping but never a stacking context, and asticky z-40header or anisolatecard is ordinary markup. The Popover API solves exactly that: opening callsshowPopover(), which paints the layer above every stacking context without moving it in the DOM. Browsers without the API fall back to plainposition: fixed, which is correct except under such an ancestor.spec/system/stacking_context_spec.rbholds this.One case does remain: an ancestor with
transform,filterorcontainbecomes the containing block for fixed descendants, which affects where the layer is positioned rather than what paints over it. -
A morph refresh resets a component to the server's state. Turbo's morph rewrites attributes in place without disconnecting the Stimulus controllers, so
connect()never runs again. The controllers re-sync onturbo:morph, so the DOM and the controller always agree — but what a component was showing is whatever the server just rendered. If a component's open state should survive a refresh, mark itdata-turbo-permanent; that is your call, not the library's. -
Indicators are hidden, not unmounted, before JavaScript runs. Radix mounts a checkbox tick or a select checkmark only while checked. The server renders them with
hiddenso the markup is right without JavaScript, and the controller detaches them on connect to match Radix exactly.
Development
bin/setup # bundle install + build Tailwind
bundle exec rake # the whole suite
bin/rubocop # Ruby style
bin/eslint # the controllers; Node is a dev dependency only
bin/console # IRB with the dummy app, to render components
cd test/dummy && bin/rails s
Then open http://localhost:3000/lookbook for the component gallery. See CONTRIBUTING.md for adding a component and re-syncing with upstream.
License
MIT. The vendored shadcn/ui sources under vendor/shadcn are MIT too; see
vendor/shadcn/LICENSE.md.