Assets โ
TailwindCSS 4 + Stimulus toolchain. CSS design tokens for theming, .pu-* component classes for consistent styling, and a Phlexi theme system for component-level overrides.
๐จ Critical โ
- Custom CSS, brand colors, or your own Stimulus controllers need
pu:core:assetsfirst. Out of the box the app serves the gem's prebuiltplutonium.css/plutonium.min.js; the generator switches it to your own bundles. Don't hand-write the Tailwind/PostCSS pipeline. - Once the app owns its JS bundle,
registerControllers(application)must be inapp/javascript/controllers/index.js(pu:core:assetsadds it). Your bundle replaces the gem's, so without it Plutonium's controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead. - Use
plutoniumTailwindConfig.mergewhen overriding the theme, plain object spread drops Plutonium's defaults. - Tokens are CSS variables, not Tailwind keys,
bg-[var(--pu-surface)], NOTbg-pu-surface. - Dark mode uses
selectorstrategy, toggledarkon<html>. The bundledcolor-modecontroller does this. - Style with
.pu-*classes first,var(--pu-*)tokens second, raw palette pairs last. Banners, badges, cards and buttons all have a.pu-*class that carries its own.darkrule. See Component classes.
Asset configuration โ
# config/initializers/plutonium.rb
Plutonium.configure do |config|
config.load_defaults 1.0
config.assets.stylesheet = "application" # your CSS file
config.assets.script = "application" # your JS file
config.assets.logo = "my_logo.png"
config.assets.favicon = "my_favicon.ico"
endGenerator โ
rails generate pu:core:assetsUntil this runs, the app serves the gem's prebuilt assets (config.assets.stylesheet defaults to plutonium.css, script to plutonium.min.js). Those are compiled from the gem's own sources, so app-side Tailwind classes, a new primary palette, token overrides and custom Stimulus controllers have nowhere to go. The generator:
- Installs
@radioactive-labs/plutonium(pinned to the gem version), Tailwind 4 and the PostCSS plugins. - Writes
tailwind.config.js(throughplutoniumTailwindConfig.merge) andpostcss.config.js. - Prepends
@import "gem:plutonium/src/css/plutonium.css";toapplication.tailwind.cssand adds@configafter@import "tailwindcss";. - Appends
registerControllers(application)toapp/javascript/controllers/index.js. - Sets
config.assets.stylesheet = "application"andconfig.assets.script = "application", and writes thebuild/build:cssscripts inpackage.json.
Step 5 is why registerControllers is not optional: the gem's plutonium.min.js calls it itself, and your application.js replaces that bundle.
Prerequisites โ
The generator aborts unless app/assets/stylesheets/application.tailwind.css and app/javascript/controllers/index.js exist, i.e. an app created with -j esbuild -c tailwind plus Stimulus. For an app without them, install the bundlers first (bin/rails javascript:install:esbuild, css:install:tailwind, stimulus:install from jsbundling-rails, cssbundling-rails and stimulus-rails), then run the generator. Don't hand-write tailwind.config.js / postcss.config.js instead: the generated ones resolve the gem path (bundle show plutonium) and load its postcss-gem-import.cjs so the gem: import works.
Package managers โ
The generator installs with the package manager rails javascript:build will use. The lockfile decides: bun.lock or bun.lockb means bun, pnpm-lock.yaml pnpm, package-lock.json npm, yarn.lock yarn. With no lockfile, bun.config.js means bun, and otherwise the first of bun, yarn, pnpm or npm found on PATH wins (yarn if none is). That is why rails new -j esbuild on a machine with bun installed produces a bun app. When the app's jsbundling-rails provides Jsbundling::PackageManager (its main branch, not yet in a release as of 1.3.1), the generator defers to it instead; that detector ignores yarn.lock and picks by PATH. Every package goes through one add with versions inline (tailwindcss@latest, @radioactive-labs/plutonium@^<gem version>), which yarn 1, yarn 2+, bun, npm and pnpm all understand.
Yarn 2+ apps get nodeLinker: node-modules written to .yarnrc.yml if no linker is set. Tailwind's PostCSS plugin does not load under Plug'n'Play.
pu:core:update and pu:docker:install use the same detection, so the Dockerfile installs bun, yarn 1, or yarn 2+ via corepack to match the app.
Tailwind config โ
Generated tailwind.config.js:
const { execSync } = require('child_process');
const plutoniumGemPath = execSync("bundle show plutonium").toString().trim();
const plutoniumTailwindConfig = require(`${plutoniumGemPath}/tailwind.options.js`);
module.exports = {
darkMode: plutoniumTailwindConfig.darkMode, // 'selector'
plugins: [].concat(plutoniumTailwindConfig.plugins),
theme: plutoniumTailwindConfig.merge(
plutoniumTailwindConfig.theme,
{ /* your overrides */ },
),
content: [
`${__dirname}/app/**/*.{erb,haml,html,slim,rb}`,
`${__dirname}/app/javascript/**/*.js`,
`${__dirname}/packages/**/app/**/*.{erb,haml,html,slim,rb}`,
].concat(plutoniumTailwindConfig.content),
};Use plutoniumTailwindConfig.merge
A plain spread (...plutoniumTailwindConfig.theme) drops the merge logic and you lose Plutonium's defaults. Always use merge(...).
Customizing colors โ
theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
extend: {
colors: {
primary: { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' },
},
},
})These are Tailwind palette colors, compiled into the CSS at build time (.pu-btn-primary is @apply bg-primary-600 ...; --pu-input-focus-ring is theme(colors.primary.500)). Recoloring primary therefore means the merge above plus a rebuild, not a --pu-* override.
Default color palette โ
| Color | Usage |
|---|---|
primary | Brand primary (turquoise default) |
secondary | Brand secondary (navy default) |
success | Success states (green) |
info | Informational (blue) |
warning | Warning (amber) |
danger | Error (red) |
accent | Highlight (coral pink) |
CSS imports โ
/* app/assets/stylesheets/application.tailwind.css */
@import "gem:plutonium/src/css/plutonium.css";
@import "tailwindcss";
@config '../../../tailwind.config.js';
/* your styles */Plutonium CSS includes core utility classes, EasyMDE (markdown editor), Slim Select, intl-tel-input, Flatpickr (date picker).
Stimulus โ
// app/javascript/controllers/index.js
import { application } from "./application"
import { registerControllers } from "@radioactive-labs/plutonium"
registerControllers(application)
// Your custom controllers...
import CustomController from "./custom_controller"
application.register("custom", CustomController)Bundled controllers โ
color-mode: dark/light mode toggleform: form handling (pre-submit, etc.)nested-resource-form-fields: nested form managementslim-select: enhanced select boxesflatpickr: date/time pickerseasymde: markdown editor- Various internal UI controllers
Custom Stimulus controller: standard pattern โ
// app/javascript/controllers/custom_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("Custom controller connected")
}
}// Register
application.register("custom", CustomController)Design tokens โ
Plutonium uses a comprehensive CSS custom-property system for consistent, themeable UI components. Tokens auto-switch with dark mode. Source: src/css/tokens.css.
Surface & backgrounds โ
/* Light */
--pu-body: #f8fafc;
--pu-surface: #ffffff;
--pu-surface-alt: #f1f5f9;
--pu-surface-raised: #ffffff;
--pu-surface-overlay: rgba(255, 255, 255, 0.95);
/* Dark (.dark class) */
--pu-body: #0f172a;
--pu-surface: #1e293b;
--pu-surface-alt: #0f172a;
--pu-surface-raised: #334155;
--pu-surface-overlay: rgba(30, 41, 59, 0.95);Text โ
/* Light */
--pu-text: #0f172a;
--pu-text-muted: #64748b;
--pu-text-subtle: #94a3b8;
/* Dark */
--pu-text: #f8fafc;
--pu-text-muted: #94a3b8;
--pu-text-subtle: #64748b;Borders, forms, cards โ
--pu-border: #e2e8f0;
--pu-border-muted: #f1f5f9;
--pu-border-strong: #cbd5e1;
--pu-input-bg: #ffffff;
--pu-input-border: #e2e8f0;
--pu-input-focus-ring: theme(colors.primary.500);
--pu-input-placeholder: #94a3b8;
--pu-card-bg: #ffffff;
--pu-card-border: #e2e8f0;Shadows, radii, spacing, transitions โ
--pu-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03), 0 1px 3px 0 rgb(0 0 0 / 0.05);
--pu-shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.04), 0 4px 6px -1px rgb(0 0 0 / 0.06);
--pu-shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.03), 0 10px 15px -3px rgb(0 0 0 / 0.08);
--pu-radius-sm: 0.375rem;
--pu-radius-md: 0.5rem;
--pu-radius-lg: 0.75rem;
--pu-radius-xl: 1rem;
--pu-radius-full: 9999px;
--pu-space-xs: 0.25rem;
--pu-space-sm: 0.5rem;
--pu-space-md: 1rem;
--pu-space-lg: 1.5rem;
--pu-space-xl: 2rem;
--pu-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--pu-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--pu-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);Customizing tokens โ
/* app/assets/stylesheets/application.tailwind.css */
@import "gem:plutonium/src/css/plutonium.css";
@import "tailwindcss";
:root {
--pu-surface: #fafafa;
--pu-border: #d1d5db;
}
.dark {
--pu-surface: #111827;
--pu-border: #374151;
}Mirror every :root override in .dark
Your stylesheet loads after Plutonium's, and :root and .dark have equal specificity, so a token you override in :root beats Plutonium's .dark value even when dark mode is active. Any color token you customize in :root without re-asserting in .dark ships your light value into dark mode, where it's typically unreadable (e.g. a translucent dark --pu-text-subtle becomes invisible on a dark surface).
That includes the shadows: src/css/tokens.css redefines --pu-shadow-sm/md/lg (and every surface, text, border, table, input, card and chart token) under .dark, so a tinted light shadow left out of your .dark block replaces the dark one.
Put dark values in a .dark { ... } block, not @media (prefers-color-scheme: dark). Dark mode is the dark class on <html> (set by the color-mode controller), so a media query ignores the user's toggle. Overrides need the app's own stylesheet after the Plutonium import (pu:core:assets); never edit the gem's tokens.css / components.css.
Using tokens in templates โ
<h1 class="text-[var(--pu-text)]">Title</h1>
<p class="text-[var(--pu-text-muted)]">Description</p>
<div class="bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]">
Content
</div>class MyComponent < Plutonium::UI::Component::Base
def view_template
div(
class: "bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]",
style: "box-shadow: var(--pu-shadow-md)"
) do
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Title" }
p(class: "text-[var(--pu-text-muted)]") { "Description" }
end
end
endComponent classes (.pu-*) โ
Ready-to-use styled components in src/css/components.css. Prefer these over hardcoded gray-X/dark:gray-Y (or warning-50 dark:warning-950) pairs. In order of preference:
- A
.pu-*class (pu-alert-warning,pu-badge-warning,pu-card,pu-btn-soft-danger). Each ships with its own.darkrule and is always in the CSS, becausecomponents.cssis part ofplutonium.csswhether the app uses the prebuilt file or imports it. - A
var(--pu-*)token (text-[var(--pu-text-muted)],border-[var(--pu-border)]) for layout around them. The token switches value under.darkand follows any theme override. - Raw palette utilities only for what neither covers. On the prebuilt
plutonium.cssthey exist only if the gem's own sources happen to use them (its Tailwindcontentscans the gem, not your app); with your own build they compile, but each needs a hand-pickeddark:twin that won't follow a rebrand.
Buttons โ
.pu-btn (base)
.pu-btn-md / -sm / -xs (size)
.pu-btn-primary / -secondary / -danger / -success / -warning / -info / -accent
.pu-btn-ghost / -outline
.pu-btn-soft-primary / -soft-danger / ...<%= form.submit "Save", class: "pu-btn pu-btn-md pu-btn-primary" %>Inputs, labels, hints, errors โ
.pu-input / -invalid / -valid
.pu-label / -required
.pu-hint / .pu-error
.pu-checkbox
.pu-toggle (switch-styled checkbox)Badges (status pills) โ
.pu-badge (base)
.pu-badge-neutral / -primary / -secondary / -success / -danger / -warning / -info / -accent<span class="pu-badge pu-badge-success">Active</span>Rendered automatically by the :badge display (enums) and :boolean display (Yes/No pills). See Displays.
Alerts (inline banners) โ
.pu-alert / -success / -warning / -danger / -info
.pu-alert-message / .pu-alert-closediv(class: "pu-alert pu-alert-warning", role: "alert") do
div(class: "pu-alert-message") { t("blog.posts.flagged_comments", count: flagged) }
endThe same banner the flash messages use (app/views/plutonium/_flash_alerts.html.erb), so it already has its dark-mode colors.
Cards, panels, tables, toolbars, empty states โ
.pu-card / .pu-card-body
.pu-panel-header / -title / -description
.pu-table-wrapper / .pu-table / -header / -header-cell / -body-row / -body-row-selected / -body-cell / .pu-selection-cell
.pu-toolbar / -text / -actions
.pu-empty-state / -icon / -title / -descriptionRuby constants โ
Plutonium::UI::ComponentClasses (in lib/plutonium/ui/component_classes.rb):
ComponentClasses::Button.classes(variant: :primary, size: :default, soft: false)
# => "pu-btn pu-btn-md pu-btn-primary"
ComponentClasses::Form::INPUT # "pu-input"
ComponentClasses::Form::LABEL # "pu-label"
ComponentClasses::Table::WRAPPER # "pu-table-wrapper"
ComponentClasses::Card::BASE # "pu-card"Migration from hardcoded classes โ
| Old | New |
|---|---|
text-gray-900 dark:text-white | text-[var(--pu-text)] |
text-gray-500 dark:text-gray-400 | text-[var(--pu-text-muted)] |
bg-gray-50 dark:bg-gray-700 | bg-[var(--pu-surface)] |
border-gray-300 dark:border-gray-600 | border-[var(--pu-border)] |
| Long input class chain | pu-input |
block mb-2 text-sm font-semibold ... | pu-label |
text-red-600 dark:text-red-400 | pu-error |
| Long button class chain | pu-btn pu-btn-md pu-btn-primary |
Phlexi component themes โ
Plutonium components use a Phlexi-based theme system for customizing Form, Display, and Table components. Each has a theme class with named style tokens.
Form theme โ
See Forms โบ Theming for the full Form theme surface.
Display theme โ
class PostDefinition < ResourceDefinition
class Display < Display
class Theme < Plutonium::UI::Display::Theme
def self.theme
super.merge(
fields_wrapper: "grid grid-cols-3 gap-8",
label: "text-sm font-bold text-[var(--pu-text-muted)] mb-1",
string: "text-lg text-[var(--pu-text)]",
markdown: "prose dark:prose-invert max-w-none"
)
end
end
end
endTheme keys: fields_wrapper, label, description, string, text, link, email, phone, markdown, json.
Table theme โ
class PostDefinition < ResourceDefinition
class Table < Table
class Theme < Plutonium::UI::Table::Theme
def self.theme
super.merge(
wrapper: "pu-table-wrapper",
base: "pu-table",
header: "pu-table-header",
header_cell: "pu-table-header-cell",
body_row: "pu-table-body-row",
body_cell: "pu-table-body-cell"
)
end
end
end
endTheme keys: wrapper, base, header, header_cell, body_row, body_cell, sort_icon.
Always super.merge(...)
Don't replace the theme wholesale. Plutonium's defaults handle invalid states, focus rings, and dark mode, super.merge keeps them.
Gotchas โ
- Stimulus controllers register silently fails. Once
config.assets.scriptpoints at the app's JS, ifregisterControllers(application)isn't called the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error: just no behavior. plutoniumTailwindConfig.mergeis mandatory. Plain spread drops defaults silently.- Tokens are CSS variables, not Tailwind keys. Use
bg-[var(--pu-surface)], notbg-pu-surface. - Dark mode is
selector, notclass. Toggle viadocument.documentElement.classList.toggle('dark'). .pu-*classes auto-switch with dark mode. Hardcodedgray-X/dark:gray-Ypairs don't get auto-updated when tokens change.
Related โ
- Forms โบ Theming: Form theme keys + override pattern
- Components:
tokensandclasseshelpers for conditional class composition - Layouts: fonts, dark-mode toggle, body attributes
