Skip to content

docs / guides

Using shadcn/ui Components

Set up shadcn/ui in a ruact app, scaffold resources with it, and use its components in your own client components.

shadcn/ui gives you React components as source files you own, styled with Tailwind. In a ruact app they live in app/javascript/components/ui/ and you use them from your own client components. This page covers the setup, the --shadcn scaffold, and using the components in code you write.

ruact itself needs none of this: the default scaffold is plain HTML. shadcn is opt-in.


Terminal window
bin/rails generate ruact:install --shadcn

This is the same install generator. Run it on a new app, or on one where ruact is already installed. With --shadcn it also writes what shadcn’s CLI checks for and refuses to start without:

  • app/javascript/styles/globals.css — the Tailwind entry. shadcn adds its design tokens to this file.
  • tsconfig.json — the @/* → app/javascript/* import alias.
  • Tailwind in package.json — tailwindcss, @tailwindcss/cli and tw-animate-css, plus a build:css script.
  • A css process in Procfile.dev — bin/dev rebuilds app/assets/builds/tailwind.css as you edit.

On an app that already has these files, it adds what is missing and keeps what you have. When it cannot finish the setup, it says so and names the fix instead of reporting the prerequisites as in place. That happens with a Tailwind older than 4, or with a Procfile.dev that already runs a different CSS watcher, such as tailwindcss-rails’ bin/rails tailwindcss:watch, which compiles its own entry rather than the one shadcn writes into. Then it installs the npm packages and prints the two commands it does not run for you.


printed by rails generate ruact:install --shadcn
npx shadcn@latest init --base radix
npx shadcn@latest add button input textarea switch select label badge table alert-dialog dropdown-menu

They are interactive and go to the network, which is why the generator prints them instead of running them. init asks for a style preset; any of them works.

--base radix is load-bearing. shadcn now defaults to Base UI, while the components ruact:scaffold --shadcn generates import Radix primitives. Accept the default and those imports do not resolve.

The add list is what the scaffold uses. Add any other component the same way: npx shadcn@latest add dialog.


Terminal window
bin/rails generate ruact:scaffold Post title:string body:text published:boolean --shadcn
bin/rails db:migrate

This is the same scaffold with the same controller, views and query, but the components are built from shadcn:

  • PostList — the list is a Table, with a DropdownMenu for the row actions on narrow screens.
  • PostForm — the form uses Input, Textarea, Switch and Select.
  • PostDeleteDialog — the confirmation is an AlertDialog.

Before writing anything, the scaffold checks that shadcn is set up and that the components it imports are installed. If they are not, it prints the commands that fix it and writes no file. The scaffold reference has every option.

Run bin/dev and open http://localhost:3000/posts.


Step 4: Use shadcn components in your own code

Section titled “4. Use shadcn components in your own code”

A shadcn component is a building block for your client component. Import it from @/components/ui/… in a file that starts with "use client":

app/javascript/components/LikeButton.tsx
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
export function LikeButton({ likes }: { likes: number }) {
const [liked, setLiked] = useState(false)
return (
<Button variant={liked ? "default" : "outline"} onClick={() => setLiked(!liked)}>
{liked ? `❤️ ${likes + 1}` : `🤍 ${likes}`}
</Button>
)
}

Then render your component from ERB, passing data as props:

app/views/posts/show.html.erb
<LikeButton likes={12} />

Compose the parts in ERB. shadcn ships a component as several parts that share state, like Dialog, DialogTrigger and DialogContent. Write them as tags in the template, the way you would in JSX. The content between the tags is ERB, so it can hold your Ruby data, Rails helpers and other components:

app/views/posts/show.html.erb
<Dialog>
<DialogTrigger>Preview</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle><%= @post.title %></DialogTitle>
</DialogHeader>
<p><%= @post.body %></p>
<LikeButton likes={12} />
</DialogContent>
</Dialog>

This needs ruact 0.0.19 or later, and every part’s file has to start with "use client": that line is what makes its exports tags. shadcn removes it from the files it writes unless components.json says "rsc": true, and shadcn init sets "rsc": false in any app that is not Next.js.

There are two ways to get the line:

  • Add it by hand to the files you compose in ERB. For the dialog above, put "use client" at the top of components/ui/dialog.tsx.
  • Set "rsc": true in components.json. Every component you add from then on keeps its "use client", and each of those files becomes a component ruact registers and bundles, whether a template uses it or not. Its exports also share one namespace with your own components: a shadcn Select replaces your own Select in templates.

Either way, npx shadcn@latest add <name> --overwrite rewrites the component’s files and the shadcn files it depends on. Re-adding dialog also rewrites button.tsx, which discards your edits and, with "rsc": false, the "use client" you added.

Without the line, <Dialog> in a template raises Ruact::UnknownComponentError. Some files, such as button and card, never have it, because shadcn does not mark them as client components. Add it yourself to use them as tags, for example <DialogTrigger asChild><Button variant="outline">Preview</Button></DialogTrigger>.

When the composition has its own logic, such as state, effects or event handlers, write it in a client component and pass it the data from ERB:

app/javascript/components/PostPreview.tsx
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
export function PostPreview({ title, body }: { title: string; body: string }) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Preview</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<p>{body}</p>
<Button onClick={() => setOpen(false)}>Done</Button>
</DialogContent>
</Dialog>
)
}
<PostPreview title={@post.title} body={@post.body} />

(dialog is not in the scaffold’s list. Add it first with npx shadcn@latest add dialog.)


shadcn init writes its theme — the CSS variables every component reads — into app/javascript/styles/globals.css, below the Tailwind import. Change colours and radii there. The css process rebuilds app/assets/builds/tailwind.css, and ruact’s layout links it through config.layout_stylesheets in config/initializers/ruact.rb. If your setup needs an extra line for that, the install generator prints it.

If the components render unstyled, check that bin/dev is running all three processes: web, vite and css. Without css, app/assets/builds/tailwind.css is never built.


ruact:scaffold --shadcn reads the shadcn version from your package.json. If the major version is not one ruact has been run against, the scaffold prints a warning and generates the components anyway. The tested majors are 1, 2 and 4; 4 is what npx shadcn@latest installs today. If you have checked a newer major yourself and the generated components work, add it to the list:

config/initializers/ruact.rb
Ruact.configure do |config|
config.shadcn_compatible_versions = [1, 2, 4, 5]
end