# Nitro Kit
Nitro Kit is a gem-owned, Phlex-only UI system for Ruby on Rails. You compose
interfaces in Ruby. The gem owns the component classes, the rendered markup, the
CSS, and the Stimulus behavior; your application owns product code and theme
overrides.
Version 2.0 is an intentional break from 1.x. There are no `nk_*` ERB helpers,
no component generators, no copied component source in your app, and no Tailwind
requirement. If you are reading 1.x material, it does not apply.
Docs: https://nitrokit.dev/docs
## Requirements
- Rails 7.0+
- phlex-rails 2.1+
- Stimulus, only for the components that enhance native behavior
- Tailwind is optional. Nitro Kit ships plain CSS and works without it.
## Install
```sh
bundle add nitro_kit
bin/rails generate nitro_kit:install
```
The install generator sets up assets and agent guidance. It never copies
component source into your application and never leaves you maintaining a fork
of the components.
Load the stylesheet in your layout:
```ruby
stylesheet_link_tag("nitro_kit", data: { turbo_track: "reload" })
```
## Composition
Direct Phlex construction is the API:
```ruby
render NitroKit::Button.new("Save", variant: :primary)
```
`NitroKit` extends `Phlex::Kit`, so including it once gives you capitalized
methods as the everyday syntax:
```ruby
class ApplicationComponent < Phlex::HTML
include NitroKit
end
class SaveButton < ApplicationComponent
def view_template
Button("Save", variant: :primary)
end
end
```
Kit methods render immediately and only work in a Phlex context. Use the
explicit `render NitroKit::Button.new(...)` form when you need the component
object itself, such as passing it to another component's slot.
Compound components expose ordinary Ruby methods:
```ruby
render NitroKit::Card.new do |card|
card.title("Workspace")
card.body { render WorkspaceSummary.new }
end
```
Blocks that have a fixed rendering order accept their text either in the
constructor or through the matching method, never both:
```ruby
render NitroKit::EmptyState.new(level: 3) do |empty|
empty.title { plain "No records for "; strong { "Production" } }
empty.description("Remove one or more filters and try again.")
end
```
## Options
Every component option is an explicit keyword. There is no catch-all `**attrs`
that swallows misspellings, and every enumerated value is validated at
construction. Unknown variants, sizes, and placements raise `ArgumentError`
rather than falling back silently.
Native attributes go through three deliberate bags:
- `html:` for ordinary attributes
- `aria:` for ARIA attributes
- `data:` for your own data attributes and additive Stimulus controllers/actions
`class:` and `style:` are rejected, including nested inside `html:`. The single
escape hatch is `desperately_need_a_class:`, which emits the class plus
`data-nk-escape="class"` so the exception is visible in the DOM.
```ruby
render NitroKit::Button.new(
"Open widget",
desperately_need_a_class: "external-widget-trigger"
)
```
## Markup contract
Nitro Kit components emit no classes. Every root carries a stable identity and
every owned part carries a component-qualified slot:
```html
```
`data-nk`, `data-slot`, `data-variant`, `data-size`, `data-nk-escape`,
`data-enhanced`, and the component-owned `data-state`, `data-disabled`,
`data-required`, `data-orientation`, `data-presentation`, `data-placement`,
`data-layout`, and `data-field-type` are reserved. Passing any of them through
`data:` raises. Your `data-controller` and `data-action` values compose
additively with Nitro's.
Native elements stay native. Nitro does not mirror browser-owned state: native
`details`/`summary` owns disclosure, `command`/`commandfor` opens dialogs,
`popover="auto"` owns dropdown visibility, and CSS owns tooltip visibility.
## Components
Actions, display, and navigation: Alert, AppNavigation, Avatar, AvatarStack,
Badge, Button, ButtonGroup, Icon, Pagination.
Forms: AppearancePicker, Checkbox, CheckboxGroup, Dropzone, Field, FieldGroup,
Fieldset, Input, Label, RadioButton, RadioButtonGroup, RichTextArea, Select,
Switch, Textarea.
Structured content and interaction: Accordion, Card, Combobox, DetailsTable,
Dialog, Dropdown, ProgressiveImage, Table, Tabs, Toast, Tooltip, Typeset.
Layout primitives: Flex, Grid, Container.
Blocks and shells: AuthShell, AppShell, SettingsLayout, Toolbar, PaginationBar,
PageHeader, StatGrid, DataSection, FormSection, DangerZone, EmptyState.
Non-visual: AppearanceBootstrap, Choice, FormBuilder.
There is no Datepicker; `Input`'s `type: :date` and `Field`'s `as: :date` are
the date control. There is no separate sortable-table component; sorting is part
of `Table`. `VStack` and `HStack` are gone; use `Flex` with an explicit `dir:`.
### Button
```ruby
render NitroKit::Button.new("Save", variant: :primary)
render NitroKit::Button.new("Delete", variant: :destructive, type: :submit)
render NitroKit::Button.new("Docs", href: docs_path, icon_end: :arrow_right)
render NitroKit::Button.new(icon: :x, label: "Dismiss", variant: :ghost, size: :sm)
```
Variants `default primary destructive ghost`. Sizes `xs sm md lg xl`. Icons are
`icon:` and `icon_end:`. Icon-only buttons require an accessible name through
`label:` or `aria:`. `loading: true` disables the control, sets `aria-busy`, and
swaps the leading icon for a spinner. Omit `variant:` for ordinary actions;
`:ghost` is for low-emphasis chrome, not routine secondary actions.
### Layout
```ruby
render NitroKit::Flex.new(dir: :col, gap: 6, align: :stretch) do
render ProfileForm.new
end
render NitroKit::Grid.new(cols: "1 sm:2 lg:3", gap: "3 lg:6") do
records.each { |record| render RecordCard.new(record) }
end
```
Every responsive property takes a scalar or a whitespace-separated string
`BASE sm:VALUE md:VALUE lg:VALUE xl:VALUE 2xl:VALUE`. Breakpoints are fixed at
`sm` 40rem, `md` 48rem, `lg` 64rem, `xl` 80rem, `2xl` 96rem. This is a small
typed layout API, not a utility language: arbitrary values, custom breakpoints,
and Tailwind class strings are rejected.
### Forms
Use Rails `form_with` and select the builder explicitly:
```ruby
form_with(model: @post, builder: NitroKit::FormBuilder) do |form|
form.group do
form.field(:title)
form.field(:category_id, as: :select, options: Category.pluck(:name, :id))
form.field(:body, as: :textarea, rows: 6)
form.field(:published, as: :checkbox, description: "Show this to everyone")
form.field(:kind, as: :radio_group, options: Post.kinds.keys)
form.field(:owner_id, as: :combobox, options: User.pluck(:name, :id))
form.submit
end
end
```
There is no `nk_form_with` or `nk_form_for`. Rails naming, IDs, values, CSRF,
multipart behavior, Active Model errors, and validation semantics are preserved
exactly. `as:` accepts `button color date datetime datetime_local email file
hidden month number password range rich_text search string tel text time url
week select combobox textarea checkbox radio radio_button radio_group switch`.
Attribute bags on builder methods: `html:`, `aria:`, and `data:` decorate the
control; `wrapper_html:`, `wrapper_aria:`, and `wrapper_data:` reach the Field
wrapper. Rails helpers Nitro does not style, such as `collection_select` and
`date_select`, raise and name their `form.field(as:)` equivalent.
### Application shell
```ruby
render NitroKit::AppShell.new(id: "workspace", layout: :sidebar) do |shell|
shell.brand { render ProductMark.new }
shell.navigation do
render NitroKit::AppNavigation.new(label: "Primary") do |navigation|
navigation.body do
navigation.item("Dashboard", href: dashboard_path, icon: :home, current: true)
navigation.item("Projects", href: projects_path, icon: :folder)
navigation.spacer
navigation.item("Settings", href: settings_path, icon: :settings)
end
end
end
shell.topbar { render AccountActions.new }
shell.main { render DashboardPage.new }
end
```
Layouts are `sidebar`, `topbar`, and `hybrid`. Exactly one `navigation` and one
`main` are required; `brand` and `topbar` are optional. The shell owns the
breakpoint, sticky placement, the skip link, and the narrow-screen drawer, which
is a real modal `dialog` so the browser owns focus containment and Escape. One
navigation tree moves between desktop and drawer; it is never cloned. Routes,
authorization, and current-destination policy stay in your application.
### Tables and sorting
```ruby
render NitroKit::Table.new(sort: query.current_sort, direction: query.direction) do |table|
table.thead do
table.tr do
table.th("Name", sort: :name, href: query.sort_url(:name))
table.th("Updated", sort: :updated_at, href: query.sort_url(:updated_at), align: :right)
end
end
table.tbody do
records.each do |record|
table.tr do
table.th(record.name, scope: :row)
table.td(record.updated_at.to_fs(:long), align: :right)
end
end
end
end
```
You supply the URLs and own the query policy. The active header renders native
`aria-sort` and a direction icon; other sortable headers render
`aria-sort="none"`. No JavaScript is involved.
### Flash and toast
```ruby
render NitroKit::Toast::FlashMessages.new(flash: flash)
```
Render it once in the application layout and keep using ordinary Rails flash
with `303 See Other` redirects. `notice` maps to the default presentation,
`alert` and `error` to error, and `success`, `warning`, and `info` to their
matching variants. The list is addressable, so a Turbo Stream can append to it:
```ruby
turbo_stream.append("nk-toast-list") do
render NitroKit::Toast::Item.new(title: "Saved", variant: :success)
end
```
Every item is `data-turbo-temporary`, so a cached page never replays stale
feedback while the region survives.
### Overlays
```ruby
render NitroKit::Dialog.new(id: "delete-project") do |dialog|
dialog.trigger("Delete project", variant: :destructive)
dialog.panel(title: "Delete this project?", description: "This cannot be undone.") do
form_with(model: @project, method: :delete) do
render NitroKit::Button.new("Delete", type: :submit, variant: :destructive)
end
dialog.close_button(label: "Cancel")
end
end
render NitroKit::Dropdown.new do |menu|
menu.trigger("Actions", icon_end: :chevron_down)
menu.item("Edit", href: edit_project_path(@project), icon: :pencil)
menu.separator
menu.item("Delete", icon: :trash, variant: :destructive, type: :submit)
end
```
Dialog uses declarative `command`/`commandfor`; Dropdown uses native
`popover="auto"`. Their controllers only add what the platform does not supply,
such as menu keyboard navigation and backdrop light dismissal.
## CSS and theming
Nitro Kit ships one browser-ready `nitro_kit.css`. Selectors target
`data-nk`, qualified `data-slot`, variant, size, and state attributes inside
`:where()`, so authored specificity is zero and your overrides always win.
Cascade layers are declared in order: tokens, reset, base, variant, size, state,
compound.
Customize by overriding documented public `--nk-*` custom properties in your own
CSS. Private `--_nk-*` variables coordinate component mechanics and are not a
theme API. Never edit the generated distribution asset.
```css
:root {
--nk-color-primary: oklch(0.55 0.2 260);
--nk-radius-md: 0.5rem;
}
```
Appearance is gem-owned. Render the bootstrap in `head` before your stylesheet
links so a persisted choice does not flash, then place a picker wherever
appearance is selected:
```ruby
render NitroKit::AppearanceBootstrap.new(default: :system, nonce: content_security_policy_nonce)
render NitroKit::AppearancePicker.new(id: "appearance", label: "Appearance")
```
`data-theme="light"` and `data-theme="dark"` describe the resolved appearance;
the light/dark/system preference is stored separately. Before JavaScript runs,
`:root` follows `prefers-color-scheme`.
An optional `nitro_kit-tailwind-v4.css` adapter maps Nitro values into Tailwind
v4 theme variables. Tailwind stays an application concern, never a Nitro runtime
dependency.
## JavaScript
Nitro Kit ships its own Stimulus controllers under the `nk--` prefix and pins
them through the engine when importmap is present. Your application still owns
Stimulus and its controller loader. No third-party JavaScript is vendored.
Controllers exist only for gaps native HTML leaves: app shell disclosure,
appearance sync, avatar image errors, checkbox indeterminate state, combobox
search, dialog light dismissal, dropdown keyboard navigation, dropzone uploads,
progressive image loading, tabs, toast timers, and tooltip Escape. Accordion,
Switch, radio buttons, sortable tables, and details tables need no JavaScript at
all.
## Component gallery
Every component is free, and every one has a live alpha example in the public
gallery at https://gallery.nitrokit.dev. Each page documents the exact
constructor, the closed vocabularies, the rendered root and slots, and the
accessibility contract. The code shown is the executable source of the preview
above it, so what you copy is what rendered.
## Nitro Kit Pro
Every component is free. Pro sells the compositional knowledge: how real screens
and workflows assemble well from those components, kept current against each
core release.
A Pro catalog item delivers two things:
- **Pattern** — the distilled teaching. When to reach for it, how it composes
from free components, the decisions and tradeoffs, the adaptation points. This
is what an agent reads first.
- **Exemplar** — the built thing. Application-owned source to retrieve and
adapt.
The Pro catalog is alpha software. External evals test whether agents can apply
patterns against the pinned core version; their outputs are QA artifacts, not
catalog state or customer payload.
Pro catalog delivery through the website and MCP is being prepared. Both will
use the same subscription-bound catalog and source bundle.
## Guidance for agents
- Compose components directly. Never generate copies of Nitro component source
into the application.
- Read the component contract before guessing an option name. Options are
closed and validated; a wrong value raises rather than degrading.
- Reach for `desperately_need_a_class:` only for genuine third-party
integration, never for styling.
- Customize through `--nk-*` custom properties, not by overriding Nitro
selectors.
- Keep product routes, authorization, query policy, and copy in application
code. Nitro owns layout, behavior, and semantics.
- Prefer native Rails and native HTML: `form_with`, ordinary redirects with
`303`, `422` for invalid submissions, Turbo Frames and Streams.
## License
Nitro Kit uses a custom license that permits use in your own projects but not
resale. See https://nitrokit.dev for licensing and Pro subscriptions.