box; exposes with_title /
with_description / with_footer block helpers
(no further sub-component classes).
### Example: Default
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open dialog" } %>
<% dialog.with_content do |content| %>
<% content.with_title { "Edit profile" } %>
<% content.with_description { "Make changes to your profile here. Click save when you're done." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Cancel" } %>
<%= render(UI::Button.new) { "Save changes" } %>
<% end %>
<%= render(UI::Input.new(type: :text, value: "Rain Root")) %>
<%= render(UI::Input.new(type: :email, value: "rainroot@example.com")) %>
<% end %>
<% end %>
```
### Example: Confirmation
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :destructive) { "Delete project" } %>
<% dialog.with_content(size: :sm) do |content| %>
<% content.with_title { "Delete this project?" } %>
<% content.with_description { "This action cannot be undone. All project data will be removed permanently." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Cancel" } %>
<%= render(UI::Button.new(variant: :destructive)) { "Delete" } %>
<% end %>
<% end %>
<% end %>
```
### Example: Custom Trigger
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(as_child: true, variant: :ghost) do %>
Review release notes
<%= render(UI::Icon.new(name: "arrow-right", size: :sm)) %>
<% end %>
<% dialog.with_content do |content| %>
<% content.with_title { "Release notes" } %>
<% content.with_description { "A custom trigger can adopt the dialog semantics without rendering a nested button." } %>
Dialog trigger attributes are merged into the anchor element.
The trigger keeps its own classes while inheriting the chosen button variant tokens.
<% end %>
<% end %>
```
### Example: Long Content
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :secondary) { "Open policy dialog" } %>
<% dialog.with_content(size: :lg) do |content| %>
<% content.with_title { "Usage policy" } %>
<% content.with_description { "Long-form modal content should remain readable and keep actions pinned at the end." } %>
Dialogs are best reserved for focused tasks, decisions, or short forms.
Because the component uses the native dialog element, focus is moved into the modal and restored to the opener when it closes.
The content element is portaled under body at runtime so the backdrop and stacking context stay predictable.
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Close" } %>
<%= render(UI::Button.new(variant: :secondary)) { "Acknowledge" } %>
<% end %>
<% end %>
<% end %>
```
### Example: Sizes
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline, size: :sm) { "Open sm" } %>
<% dialog.with_content(size: :sm) do |content| %>
<% content.with_title { "Small dialog" } %>
<% content.with_description { "This is the smallest dialog size, great for confirmations." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Cancel" } %>
<%= render(UI::Button.new(size: :sm)) { "Confirm" } %>
<% end %>
<% end %>
<% end %>
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open default" } %>
<% dialog.with_content do |content| %>
<% content.with_title { "Default dialog" } %>
<% content.with_description { "This is the default dialog size for most use cases." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Cancel" } %>
<%= render(UI::Button.new) { "Confirm" } %>
<% end %>
<% end %>
<% end %>
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open lg" } %>
<% dialog.with_content(size: :lg) do |content| %>
<% content.with_title { "Large dialog" } %>
<% content.with_description { "This is a large dialog, useful for forms and detailed content." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Cancel" } %>
<%= render(UI::Button.new) { "Confirm" } %>
<% end %>
<% end %>
<% end %>
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open xl" } %>
<% dialog.with_content(size: :xl) do |content| %>
<% content.with_title { "Extra large dialog" } %>
<% content.with_description { "This is the largest dialog size, ideal for complex workflows." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Cancel" } %>
<%= render(UI::Button.new) { "Confirm" } %>
<% end %>
<% end %>
<% end %>
```
### Example: Without Close Button
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open dialog" } %>
<% dialog.with_content(show_close: false) do |content| %>
<% content.with_title { "No close button" } %>
<% content.with_description { "This dialog hides the default close button. Users must interact with the footer actions to dismiss it." } %>
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Dismiss" } %>
<%= render(UI::Button.new) { "Continue" } %>
<% end %>
<% end %>
<% end %>
```
### Example: Without Title
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open dialog" } %>
<% dialog.with_content do %>
This dialog has no title or description header.
Use this pattern when the content speaks for itself or when you want a cleaner, minimal modal.
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Close" } %>
<%= render(UI::Button.new) { "Okay" } %>
<% end %>
<% end %>
```
### Example: Scrollable Body
```erb
<%= render(UI::Dialog.new) do |dialog| %>
<% dialog.with_trigger(variant: :secondary) { "Open scrollable dialog" } %>
<% dialog.with_content(size: :lg) do |content| %>
<% content.with_title { "Terms of Service" } %>
<% content.with_description { "Please review the full terms before continuing." } %>
1. Acceptance of Terms
By accessing or using our services, you agree to be bound by these Terms of Service. If you do not agree to all the terms and conditions, you may not access the service.
2. Changes to Terms
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. It is your responsibility to review these Terms periodically for changes.
3. Use License
Permission is granted to temporarily use the materials on our website for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title.
4. Disclaimer
The materials on our website are provided on an 'as is' basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.
5. Limitations
In no event shall we or our suppliers be liable for any damages arising out of the use or inability to use the materials on our website, even if we or an authorized representative has been notified orally or in writing of the possibility of such damage.
6. Accuracy of Materials
The materials appearing on our website could include technical, typographical, or photographic errors. We do not warrant that any of the materials on our website are accurate, complete, or current.
7. Links
We have not reviewed all of the sites linked to our website and are not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by us of the site.
8. Governing Law
These terms and conditions are governed by and construed in accordance with the laws of the jurisdiction in which we operate, and you irrevocably submit to the exclusive jurisdiction of the courts in that location.
<% content.with_footer do %>
<%= render(UI::Dialog::Close.new(variant: :outline, class: "static")) { "Decline" } %>
<%= render(UI::Button.new) { "Accept" } %>
<% end %>
<% end %>
<% end %>
```
## Divider (`UI::Divider`)
A horizontal or vertical rule, optionally labelled.
Add it: `bin/rails generate velvetkit:add divider`
### Arguments
```ruby
UI::Divider.new(orientation: :horizontal, label: nil, class: nil, **html_attrs)
```
### Example: Horizontal
```erb
<%= render(UI::Divider.new) %>
Content between dividers
<%= render(UI::Divider.new) %>
```
### Example: Vertical
```erb
<%= render(UI::Divider.new(orientation: :vertical)) %>
```
### Example: With Label
```erb
<%= render(UI::Divider.new(label: "OR")) %>
<%= render(UI::Divider.new(label: "Section Divider")) %>
<%= render(UI::Divider.new(label: "Continue")) %>
```
## Form field (`UI::FormField`)
A label, hint and error message wired to any form control.
Add it: `bin/rails generate velvetkit:add form_field`
Also adds: `label`
### Arguments
```ruby
UI::FormField.new(label: nil, hint: nil, error: nil, required: false, disabled: false, label_size: :default, label_weight: :medium, form: nil, field: nil, for: nil, class: nil, **html_attrs)
```
### Notes
A label, a control, a hint and an error message, wired together: the label
points at the control, and the control is described by the hint and error.
Input, Textarea, NativeSelect and Select render through it, so they take
`hint:` and `error:` directly. Wrap any other control yourself and spread
`field.control_attributes` onto it:
<%= render(UI::FormField.new(label: "Colour", hint: "Used for your avatar")) do |field| %>
<%= tag.input(type: "color", name: "colour", **field.control_attributes) %>
<% end %>
`error:` takes a message, or `true` to mark the control invalid without one.
Bound to a form (`form:` + `field:`), the model's errors on that attribute
are the message (for `team_id`, the `team` association's too, where
belongs_to puts "must exist"), and the attribute's human name is the label
(`label:` overrides it, `label: false` opts out). Nothing to show means no
wrapper: just the control.
### Example: On the built-in controls
```erb
<%= render(UI::Input.new(type: :email, label: "Email", hint: "We'll only use it to send receipts.", placeholder: "you@example.com")) %>
<%= render(UI::Input.new(label: "Username", value: "a", error: "Use at least 3 characters.")) %>
<%= render(UI::Textarea.new(label: "Bio", hint: "Shown on your public profile.", rows: 3)) %>
<%= render(UI::NativeSelect.new(label: "Team", options: %w[Design Engineering], prompt: "Choose a team", error: "Choose a team to continue.")) %>
<%= render(UI::Select.new(label: "Plan", options: %w[Free Pro Team], hint: "You can change plans at any time.")) %>
```
### Example: Errors from the model
```erb
<%# In an app, @signup comes back from a failed save. Bound fields show its errors on their own. %>
<% signup = Struct.new(:email, :bio) do
include ActiveModel::Validations
def self.model_name = ActiveModel::Name.new(self, nil, "Signup")
validates :email, presence: true
validates :bio, length: { minimum: 20 }
end.new("", "Hi").tap(&:validate) %>
<%= form_with(model: signup, url: "#", class: "flex flex-col gap-6 max-w-md") do |f| %>
<%= render(UI::Input.new(form: f, field: :email, type: :email)) %>
<%= render(UI::Textarea.new(form: f, field: :bio, hint: "A sentence or two about you.")) %>
<% end %>
```
### Example: Your own control
```erb
<%= render(UI::FormField.new(label: "Accent colour", hint: "Used for your avatar and links.")) do |field| %>
<%= tag.input(type: "color", name: "accent", value: "#4d1a7f",
class: "h-9 w-16 rounded-md border border-input bg-background p-1", **field.control_attributes) %>
<% end %>
```
## Icon (`UI::Icon`)
A Lucide icon.
Add it: `bin/rails generate velvetkit:add icon`
### Arguments
```ruby
UI::Icon.new(name:, size: :default, label: nil, class: nil, **html_attrs)
```
### Example: All Sizes
```erb
<%= render(UI::Icon.new(name: "menu", size: :xs)) %>
<%= render(UI::Icon.new(name: "menu", size: :sm)) %>
<%= render(UI::Icon.new(name: "menu", size: :default)) %>
<%= render(UI::Icon.new(name: "menu", size: :lg)) %>
<%= render(UI::Icon.new(name: "menu", size: :xl)) %>
```
### Example: Common Icons
```erb
<% %w[menu arrow-right globe badge-check circle-alert x circle-check eye eye-off lock chevrons-up-down layout-dashboard panel-left user loader].each do |name| %>
<%= render(UI::Icon.new(name: name)) %>
<%= name %>
<% end %>
```
## Image (`UI::Image`)
An image with consistent sizing and corner radius.
Add it: `bin/rails generate velvetkit:add image`
### Arguments
```ruby
UI::Image.new(src:, rounded: :sm, width: nil, height: nil, alt: nil, loading: :lazy, class: nil, **html_attrs)
```
### Notes
UI::Image — asset-backed with radius tokens and lazy loading.
Replaces Velvet::Image, which wrapped every image in a Velvet::Box and passed
`width:`/`height:` to that wrapper as inline styles — so the div was sized and
the image itself was not. Here the dimensions land on the .
Usage:
<%= render(UI::Image.new(src: "logo.png", rounded: :lg, width: "100px")) %>
### Example: Radii
```erb
<% %i[none sm md lg xl full].each do |r| %>
<%= render(UI::Image.new(src: "/icon.png", rounded: r, width: "48px", height: "48px")) %>
<%= r %>
<% end %>
```
### Example: Sizing
```erb
<%# Dimensions land on the itself, not on a wrapper. %>
<% ["48px", "96px", "160px"].each do |w| %>
<%= render(UI::Image.new(src: "/icon.png", rounded: :md, width: w)) %>
width: <%= w %>
<% end %>
```
## Input (`UI::Input`)
A text field with label, hint and error message, standalone or with a form builder.
Add it: `bin/rails generate velvetkit:add input`
Also adds: `label`, `form_field`
### Arguments
```ruby
UI::Input.new(type: :text, size: :default, variant: :default, label: nil, label_size: :default, label_weight: :medium, hint: nil, required: false, error: nil, disabled: false, form: nil, field: nil, class: nil, **html_attrs)
```
### Example: All Sizes
```erb
<%= render(UI::Input.new(size: :sm, placeholder: "Small (sm)")) %>
<%= render(UI::Input.new(size: :default, placeholder: "Default")) %>
<%= render(UI::Input.new(size: :lg, placeholder: "Large (lg)")) %>
```
### Example: States
```erb
<%= render(UI::Input.new(placeholder: "Default")) %>
<%= render(UI::Input.new(placeholder: "With value", value: "hello@example.com")) %>
<%= render(UI::Input.new(placeholder: "Error state", variant: :error, error: true)) %>
<%= render(UI::Input.new(placeholder: "Disabled", disabled: true)) %>
<%= render(UI::Input.new(type: :password, placeholder: "Password")) %>
```
### Example: With Labels
```erb
<%= render(UI::Input.new(label: "Email", placeholder: "you@example.com")) %>
<%= render(UI::Input.new(label: "Password", type: :password, placeholder: "Enter password")) %>
<%= render(UI::Input.new(label: "Required field", required: true, placeholder: "Cannot be blank")) %>
<%= render(UI::Input.new(label: "Required + error", required: true, error: true, variant: :error, placeholder: "Invalid value")) %>
<%= render(UI::Input.new(label: "Disabled", disabled: true, placeholder: "Locked")) %>
<%= render(UI::Input.new(label: "Small label", label_size: :sm, placeholder: "Small")) %>
<%= render(UI::Input.new(label: "Large label", label_size: :lg, placeholder: "Large")) %>
<%= render(UI::Input.new(label: "Semibold label", label_weight: :semibold, placeholder: "Bolder label")) %>
```
### Example: Hint and Error
```erb
<%= render(UI::Input.new(label: "Email", type: :email, hint: "We'll only use it to send receipts.")) %>
<%= render(UI::Input.new(label: "Username", value: "a", error: "Use at least 3 characters.")) %>
```
## Input group (`UI::InputGroup`)
A text field with addons before or after it.
Add it: `bin/rails generate velvetkit:add input_group`
Also adds: `icon`, `spinner`, `button`, `label`, `form_field`, `input`
### Arguments
```ruby
UI::InputGroup.new(class: nil, **html_attrs)
```
Slots: `with_addon`, `with_field`, `with_icon`, `with_icon_button`
### Notes
UI::InputGroup — wrapper that visually merges a UI::Input with one or more
Addon decorators (icons, text, icon-buttons). The wrapper owns the border and
focus ring; the inner Field strips its own so there's no double border.
The wrapper's focus ring inherits from UI::Base::FOCUS_RING (same as Button)
wrapped in has-[] selectors, so changes propagate automatically.
Usage:
<%= render(UI::InputGroup.new) do |g| %>
<%= g.with_addon(align: "inline-start") { |a| a.with_icon(name: "mail") } %>
<%= g.with_field(type: :email, placeholder: "you@example.com") %>
<% end %>
### Example: All Addons
```erb
<%# Gallery of all addon configurations %>
<%# Icon prefix %>
<%= render(UI::InputGroup.new) do |g| %>
<%= g.with_addon(align: "inline-start") { |a| a.with_icon(name: "mail") } %>
<%= g.with_field(type: :email, placeholder: "Email") %>
<% end %>
<%# Text prefix %>
<%= render(UI::InputGroup.new) do |g| %>
<%= g.with_addon(align: "inline-start") { "https://" } %>
<%= g.with_field(placeholder: "yoursite.com") %>
<% end %>
<%# Icon suffix %>
<%= render(UI::InputGroup.new) do |g| %>
<%= g.with_field(placeholder: "Search…") %>
<%= g.with_addon(align: "inline-end") { |a| a.with_icon(name: "search") } %>
<% end %>
<%# Both sides %>
<%= render(UI::InputGroup.new) do |g| %>
<%= g.with_addon(align: "inline-start") { "$" } %>
<%= g.with_field(type: :number, placeholder: "0.00") %>
<%= g.with_addon(align: "inline-end") { "USD" } %>
<% end %>
<%# Error state %>
<%= render(UI::InputGroup.new) do |g| %>
<%= g.with_addon(align: "inline-start") { |a| a.with_icon(name: "mail") } %>
<%= g.with_field(type: :email, placeholder: "Invalid email", error: true) %>
<% end %>
```
## Label (`UI::Label`)
A form label with a required marker.
Add it: `bin/rails generate velvetkit:add label`
### Arguments
```ruby
UI::Label.new(size: :default, weight: :medium, required: false, disabled: false, for: nil, class: nil, **html_attrs)
```
### Example: All Sizes
```erb
<%= render(UI::Label.new(size: :xs)) { "Extra Small (xs)" } %>
<%= render(UI::Label.new(size: :sm)) { "Small (sm)" } %>
<%= render(UI::Label.new(size: :default)) { "Default (sm)" } %>
<%= render(UI::Label.new(size: :lg)) { "Large (base)" } %>
```
### Example: All Weights
```erb
<%= render(UI::Label.new(weight: :normal)) { "Normal weight" } %>
<%= render(UI::Label.new(weight: :medium)) { "Medium weight (default)" } %>
<%= render(UI::Label.new(weight: :semibold)) { "Semibold weight" } %>
```
### Example: States
```erb
<%= render(UI::Label.new) { "Default" } %>
<%= render(UI::Label.new(required: true)) { "Required field" } %>
<%= render(UI::Label.new(disabled: true)) { "Disabled" } %>
<%= render(UI::Label.new(for: "email")) { "With for attribute" } %>
```
## Link (`UI::Link`)
A link styled as text or as a button.
Add it: `bin/rails generate velvetkit:add link`
Also adds: `icon`
### Arguments
```ruby
UI::Link.new(href: "#", variant: :link, size: :default, button_variant: false, truncate: false, class: nil, **html_attrs)
```
Slots: `with_leading_icon`, `with_trailing_icon`
### Example: Variants
```erb
<%= render(UI::Link.new(href: "#", variant: :link)) { "Link" } %>
<%= render(UI::Link.new(href: "#", variant: :default, button_variant: true)) { "Default" } %>
<%= render(UI::Link.new(href: "#", variant: :secondary, button_variant: true)) { "Secondary" } %>
<%= render(UI::Link.new(href: "#", variant: :outline, button_variant: true)) { "Outline" } %>
<%= render(UI::Link.new(href: "#", variant: :ghost, button_variant: true)) { "Ghost" } %>
<%= render(UI::Link.new(href: "#", variant: :destructive, button_variant: true)) { "Destructive" } %>
```
### Example: Sizes
```erb
<%= render(UI::Link.new(href: "#", variant: :default, size: :sm, button_variant: true)) { "Small" } %>
<%= render(UI::Link.new(href: "#", variant: :default, size: :default, button_variant: true)) { "Default" } %>
<%= render(UI::Link.new(href: "#", variant: :default, size: :lg, button_variant: true)) { "Large" } %>
```
### Example: With Icons
```erb
<%= render(UI::Link.new(href: "#", variant: :link)) do |link| %>
<% link.with_leading_icon(name: :info) %>
Learn more
<% end %>
<%= render(UI::Link.new(href: "#", variant: :default)) do |link| %>
<% link.with_trailing_icon(name: "arrow-right") %>
Continue
<% end %>
```
## Menu (`UI::Menu`)
A dropdown menu with keyboard navigation.
Add it: `bin/rails generate velvetkit:add menu`
Also adds: `icon`, `spinner`, `button`
Stimulus: `ui--menu`
Installs: `@floating-ui/dom`
### Arguments
```ruby
UI::Menu.new(class: nil, **html_attrs)
```
Slots: `with_trigger`, `with_menu_content`, `with_content`, `with_item`, `with_label`, `with_divider`
### Example: Variants
```erb
Default trigger
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open Menu" } %>
<% menu.with_content do |content| %>
<% content.with_item("Profile") %>
<% content.with_item("Settings") %>
<% end %>
<% end %>
Icon trigger
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger(variant: :outline, size: :icon) do %>
<%= render(UI::Icon.new(name: "ellipsis-vertical", size: :sm)) %>
<% end %>
<% menu.with_content do |content| %>
<% content.with_item("Edit") %>
<% content.with_item("Delete", variant: :destructive) %>
<% end %>
<% end %>
Items with icons
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Actions" } %>
<% menu.with_content do |content| %>
<% content.with_label("Account") %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "user", size: :sm)) %>
Profile
<% end %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "credit-card", size: :sm)) %>
Billing
<% end %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "settings", size: :sm)) %>
Settings
<% end %>
<% content.with_divider %>
<% content.with_label("Team") %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "users", size: :sm)) %>
Invite users
<% end %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "plus", size: :sm)) %>
New team
<% end %>
<% content.with_divider %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "git-branch", size: :sm)) %>
Repository
<% end %>
<% content.with_item do %>
<%= render(UI::Icon.new(name: "life-buoy", size: :sm)) %>
Support
<% end %>
<% content.with_item(disabled: true) do %>
<%= render(UI::Icon.new(name: "cloud", size: :sm)) %>
API
<% end %>
<% content.with_divider %>
<% content.with_item(variant: :destructive) do %>
<%= render(UI::Icon.new(name: "log-out", size: :sm)) %>
Log out
<% end %>
<% end %>
<% end %>
With links
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Navigation" } %>
<% menu.with_content do |content| %>
<% content.with_item("Dashboard", href: "/dashboard") %>
<% content.with_item("Profile", href: "/profile") %>
<% end %>
<% end %>
Disabled item
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Options" } %>
<% menu.with_content do |content| %>
<% content.with_item("Enabled") %>
<% content.with_item("Disabled", disabled: true) %>
<% end %>
<% end %>
Positions
bottom-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :bottom, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
bottom-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :bottom, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
bottom-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :bottom, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
top-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :top, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
top-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :top, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
top-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :top, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
left-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :left, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
left-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :left, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
left-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :left, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
right-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :right, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
right-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :right, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
right-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :right, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
```
### Example: Positions
```erb
bottom-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :bottom, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
bottom-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :bottom, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
bottom-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :bottom, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
top-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :top, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
top-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :top, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
top-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :top, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
left-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :left, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
left-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :left, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
left-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :left, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
right-start
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :right, align: :start) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
right-center
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :right, align: :center) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
right-end
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger { "Open" } %>
<% menu.with_content(side: :right, align: :end) do |content| %>
<% content.with_item("Item 1") %>
<% content.with_item("Item 2") %>
<% end %>
<% end %>
```
## Pagination (`UI::Pagination`)
Page links for a long list, from a Pagy object.
Add it: `bin/rails generate velvetkit:add pagination`
Also adds: `icon`, `link`
Installs: `pagy`
### Arguments
```ruby
UI::Pagination.new(pagy:, label: nil, class: nil, **html_attrs, &url_builder)
```
### Notes
UI::Pagination — page links for a long list.
Follows shadcn's structure (nav > ul > li > a, ghost links with the current
page outlined) and takes a Pagy::Offset, so controllers stay conventional.
The series is computed here rather than read from Pagy, whose own #series is
protected in v43 and whose gap marker differs by version.
Usage:
<%= render(UI::Pagination.new(pagy: @pagy) { |page| section_path(@section, page: page) }) %>
### Example: States
```erb
<% examples = {
"First page" => [240, 1],
"Middle" => [240, 5],
"Last page" => [240, 10],
"Long range, collapsed" => [2500, 50],
"Single page (renders nothing)" => [10, 1]
} %>
<% examples.each do |label, (count, page)| %>
<%= label %>
<%= render(UI::Pagination.new(pagy: Pagy::Offset.new(count: count, page: page, limit: 25))) %>
<% end %>
```
## Password input (`UI::PasswordInput`)
A password field with a show/hide toggle.
Add it: `bin/rails generate velvetkit:add password_input`
Also adds: `label`, `form_field`, `icon`, `spinner`, `button`, `input`, `input_group`
Stimulus: `ui--password-visibility`
### Arguments
```ruby
UI::PasswordInput.new(form: nil, field: nil, label: nil, hint: nil, required: false, disabled: false, error: nil, class: nil, **html_attrs)
```
### Notes
UI::PasswordInput — password field with a show/hide toggle.
Composes UI::InputGroup so the border, focus ring and disabled styling stay
identical to every other grouped input; the toggle is an inline-end addon
driven by the `ui--password-visibility` Stimulus controller.
Velvet::PasswordInput hardcoded a placeholder (shadowing the caller's) and
accepted `rounded:`/`full_width:`/`variant:`/`size:`/`mb:` that nothing read.
Those are gone. Its toggle was also dead — no controller was ever registered.
Usage:
<%= render(UI::PasswordInput.new(form: f, field: :password, required: true)) %>
<%= render(UI::PasswordInput.new(name: "user[password]")) %>
### Example: States
```erb
<%# Click the eye to toggle masking — driven by the ui--password-visibility controller. %>
Default
<%= render(UI::PasswordInput.new(name: "password_default", placeholder: "Enter your password")) %>
Required
<%= render(UI::PasswordInput.new(name: "password_required", required: true)) %>
Error
<%= render(UI::PasswordInput.new(name: "password_error", error: true)) %>
Disabled
<%= render(UI::PasswordInput.new(name: "password_disabled", disabled: true)) %>
```
## Popover (`UI::Popover`)
Floating content anchored to a trigger.
Add it: `bin/rails generate velvetkit:add popover`
Also adds: `icon`, `spinner`, `button`
Stimulus: `ui--popover`
Installs: `@floating-ui/dom`
### Arguments
```ruby
UI::Popover.new(class: nil, **html_attrs)
```
Slots: `with_trigger`, `with_popover_content`, `with_content`, `with_title`
### Example: Default
```erb
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger(variant: :default) { "Open Popover" } %>
<% popover.with_content do |content| %>
<% content.with_title { "Popover Title" } %>
This is a simple popover with a title and some content.
<% end %>
<% end %>
```
### Example: Positions
```erb
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Top" } %>
<% popover.with_content(side: :top) do |content| %>
<% content.with_title { "Top Position" } %>
This popover appears above the trigger.
<% end %>
<% end %>
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Right" } %>
<% popover.with_content(side: :right) do |content| %>
<% content.with_title { "Right Position" } %>
This popover appears to the right.
<% end %>
<% end %>
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Bottom" } %>
<% popover.with_content(side: :bottom) do |content| %>
<% content.with_title { "Bottom Position" } %>
This popover appears below the trigger.
<% end %>
<% end %>
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Left" } %>
<% popover.with_content(side: :left) do |content| %>
<% content.with_title { "Left Position" } %>
This popover appears to the left.
<% end %>
<% end %>
```
### Example: With Close Button
```erb
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Open Popover" } %>
<% popover.with_content(show_close: true) do |content| %>
<% content.with_title { "With Close Button" } %>
This popover has a close button in the top-right corner.
You can also close it by clicking outside, pressing Escape, or tabbing away.
<% end %>
<% end %>
```
### Example: With Rich Content
```erb
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Open Rich Popover" } %>
<% popover.with_content(show_close: true) do |content| %>
<% content.with_title { "User Profile" } %>
<%= render(UI::Avatar.new(src: nil, fallback: "JD")) %>
John Doe
john@example.com
<%= render(UI::Button.new(size: :sm)) { "View Profile" } %>
<%= render(UI::Button.new(variant: :outline, size: :sm)) { "Settings" } %>
<% end %>
<% end %>
```
### Example: Without Title
```erb
<%= render(UI::Popover.new) do |popover| %>
<% popover.with_trigger { "Open Popover" } %>
<% popover.with_content do %>
This popover has no title, just content. Perfect for simple messages or actions.
<%= render(UI::Button.new(size: :sm)) { "Take Action" } %>
<% end %>
<% end %>
```
## Radio group (`UI::RadioGroup`)
A set of radio buttons.
Add it: `bin/rails generate velvetkit:add radio_group`
### Arguments
```ruby
UI::RadioGroup.new(name: nil, value: nil, size: :default, required: false, disabled: false, class: nil, **html_attrs)
```
Slots: `with_item`
### Example: Default
```erb
Default
<%= render(UI::RadioGroup.new(name: "default_plan", value: "free")) do |rg| %>
<% rg.with_item(value: "free", label: "Free plan") %>
<% rg.with_item(value: "pro", label: "Pro plan — $9/mo") %>
<% rg.with_item(value: "enterprise", label: "Enterprise — Contact us") %>
<% end %>
```
### Example: With Required
```erb
Required
<%= render(UI::RadioGroup.new(name: "required_plan", value: "pro", required: true)) do |rg| %>
<% rg.with_item(value: "free", label: "Free plan") %>
<% rg.with_item(value: "pro", label: "Pro plan — $9/mo") %>
<% rg.with_item(value: "enterprise", label: "Enterprise — Contact us") %>
<% end %>
```
### Example: Disabled
```erb
Disabled
<%= render(UI::RadioGroup.new(name: "disabled_plan", disabled: true)) do |rg| %>
<% rg.with_item(value: "free", label: "Free plan") %>
<% rg.with_item(value: "pro", label: "Pro plan (selected)", checked: true) %>
<% end %>
Individual item disabled
<%= render(UI::RadioGroup.new(name: "partial_disabled", value: "free")) do |rg| %>
<% rg.with_item(value: "free", label: "Free plan") %>
<% rg.with_item(value: "pro", label: "Pro plan (unavailable)", disabled: true) %>
<% rg.with_item(value: "enterprise", label: "Enterprise") %>
<% end %>
```
## Native select (`UI::NativeSelect`)
The browser's own select, styled closed; best for phones' system pickers.
Add it: `bin/rails generate velvetkit:add native_select`
Also adds: `label`, `form_field`, `icon`
### Arguments
```ruby
UI::NativeSelect.new(name: nil, options: [], selected: nil, prompt: nil, label: nil, hint: nil, size: :default, required: false, error: nil, disabled: false, form: nil, field: nil, class: nil, **html_attrs)
```
### Notes
UI::NativeSelect — the browser's own , styled closed.
UI::Select is the styled dropdown. Reach for this one when the platform
control is the better fit: on a phone it opens the system picker, which is
easier to use with a thumb than any custom list, and it needs no JavaScript.
The open list is drawn by the operating system, so it can't be themed.
Usage:
<%= render(UI::NativeSelect.new(form: f, field: :team_id, label: "Team",
options: @teams.map { [_1.name, _1.id] },
prompt: "Choose a team")) %>
### Example: Sizes
```erb
<% %i[sm default lg].each do |size| %>
<%= render(UI::NativeSelect.new(name: "s_#{size}", options: UI::NativeSelectPreview::SECTIONS,
prompt: "Choose a section", size: size, label: size.to_s)) %>
<% end %>
```
### Example: States
```erb
<%= render(UI::NativeSelect.new(name: "a", options: UI::NativeSelectPreview::SECTIONS, label: "Default")) %>
<%= render(UI::NativeSelect.new(name: "b", options: UI::NativeSelectPreview::SECTIONS, label: "Required", required: true)) %>
<%= render(UI::NativeSelect.new(name: "c", options: UI::NativeSelectPreview::SECTIONS, label: "Error", error: true)) %>
<%= render(UI::NativeSelect.new(name: "d", options: UI::NativeSelectPreview::SECTIONS, label: "Disabled", disabled: true)) %>
```
## Select (`UI::Select`)
A styled dropdown with keyboard navigation and type-ahead, posting like a native select.
Add it: `bin/rails generate velvetkit:add select`
Also adds: `label`, `form_field`, `icon`
Stimulus: `ui--select`
Installs: `@floating-ui/dom`
### Arguments
```ruby
UI::Select.new(name: nil, options: [], selected: nil, prompt: nil, label: nil, hint: nil, size: :default, searchable: false, multiple: false, required: false, error: nil, disabled: false, form: nil, field: nil, class: nil, **html_attrs)
```
### Notes
UI::Select — a dropdown for choosing one value, or several, styled to match the kit.
The visible part is a WAI-ARIA "select-only combobox": a button with
role=combobox and a floating role=listbox. Focus stays on the button while
the arrow keys move through the options, as the pattern specifies.
The value lives in a visually hidden native . That keeps everything a
form already relies on: the field submits under its name, `required` blocks
submission, form reset works, the form builder binds it, and anything
listening for `change` on it still hears one. The Stimulus controller only
mirrors the choice into it.
`searchable: true` adds a search box that filters the list; `multiple: true`
lets people pick several, posting `name[]` like a native multiple select.
UI::NativeSelect is the browser's own control, for when a phone's system
picker is the better fit.
Usage:
<%= render(UI::Select.new(form: f, field: :plan, label: "Plan",
options: [["Free", "free"], ["Pro", "pro"]],
prompt: "Choose a plan")) %>
### Example: Default
```erb
<%= render(UI::Select.new(name: "plan", label: "Plan", prompt: "Choose a plan",
options: [ [ "Free", "free" ], [ "Team", "team" ], [ "Business", "business" ] ])) %>
```
### Example: States
```erb
<%= render(UI::Select.new(name: "a", label: "Selected", options: %w[Viewer Editor Admin], selected: "Editor")) %>
<%= render(UI::Select.new(name: "b", label: "Required", options: %w[Viewer Editor Admin], prompt: "Choose a role", required: true)) %>
<%= render(UI::Select.new(name: "c", label: "With an error", options: %w[Viewer Editor Admin], prompt: "Choose a role", error: true)) %>
<%= render(UI::Select.new(name: "d", label: "Disabled", options: %w[Viewer Editor Admin], selected: "Viewer", disabled: true)) %>
```
### Example: Sizes
```erb
<% %i[sm default lg].each do |size| %>
<%= render(UI::Select.new(name: "size_#{size}", label: size.to_s, size: size, options: %w[Daily Weekly Monthly], selected: "Weekly")) %>
<% end %>
```
### Example: Searchable
```erb
<%# Searchable: open it and type to filter. Arrow keys and Enter work from the search box. %>
<%= render(UI::Select.new(name: "time_zone", label: "Time zone", prompt: "Choose a time zone", searchable: true,
options: %w[Auckland Bangkok Berlin Cairo Chicago Dubai Honolulu Istanbul Jakarta Kolkata Lagos Lima London Madrid Mexico\ City Moscow Nairobi New\ York Paris Santiago Seoul Singapore Sydney Tokyo Toronto])) %>
```
### Example: Multiple
```erb
<%# Stays open while you tick; past two choices the trigger counts them. %>
<%= render(UI::Select.new(name: "reviewers", label: "Reviewers", prompt: "Add reviewers", multiple: true, searchable: true,
options: [ [ "Asha Kulkarni", 1 ], [ "Ravi Menon", 2 ], [ "Sara Joseph", 3 ], [ "Tomás Ortega", 4 ], [ "Mei Lin", 5 ] ],
selected: [ 1, 2 ])) %>
```
## Sheet (`UI::Sheet`)
A panel that slides in from an edge of the screen.
Add it: `bin/rails generate velvetkit:add sheet`
Also adds: `icon`, `spinner`, `button`, `dialog`
Stimulus: `ui--dialog`
### Arguments
```ruby
UI::Sheet.new(class: nil, **html_attrs)
```
Slots: `with_trigger`, `with_body`, `with_content`, `with_title`, `with_description`, `with_footer`
### Notes
UI::Sheet — side-anchored modal panel backed by the native element.
Reuses the ui--dialog controller and follows the same compound API as
UI::Dialog, but anchors the panel to an edge of the viewport instead of
centering it.
Compound:
UI::Sheet
UI::Sheet::Trigger — click-to-open wrapper (inherits from Dialog::Trigger)
UI::Sheet::Content — the + fixed panel; inline title / description / footer
### Example: Right
```erb
<%= render(UI::Sheet.new) do |sheet| %>
<% sheet.with_trigger { "Open right sheet" } %>
<% sheet.with_content(side: :right) do |content| %>
<% content.with_title { "Notifications" } %>
<% content.with_description { "Manage your notification preferences." } %>
Right sheets work well for settings and secondary tasks.
They reuse the dialog controller but anchor the panel to a side.
<% end %>
<% end %>
```
### Example: Left
```erb
<%= render(UI::Sheet.new) do |sheet| %>
<% sheet.with_trigger(variant: :secondary) { "Open left sheet" } %>
<% sheet.with_content(side: :left) do |content| %>
<% content.with_title { "Project filters" } %>
<% content.with_description { "Filter and refine your current project list." } %>
Left sheets are useful for navigation and faceted filtering.
<% end %>
<% end %>
```
### Example: Top
```erb
<%= render(UI::Sheet.new) do |sheet| %>
<% sheet.with_trigger(variant: :outline) { "Open top sheet" } %>
<% sheet.with_content(side: :top) do |content| %>
<% content.with_title { "Command palette" } %>
<% content.with_description { "Quick actions and navigation in a compact top sheet." } %>
<%= render(UI::Input.new(type: :search, placeholder: "Search commands")) %>
<% end %>
<% end %>
```
### Example: Bottom
```erb
<%= render(UI::Sheet.new) do |sheet| %>
<% sheet.with_trigger(variant: :secondary) { "Open bottom sheet" } %>
<% sheet.with_content(side: :bottom) do |content| %>
<% content.with_title { "Mobile actions" } %>
<% content.with_description { "Bottom sheets are suited to mobile-friendly action lists." } %>
<% content.with_footer do %>
<%= render(UI::Sheet::Close.new(variant: :outline)) { "Dismiss" } %>
<%= render(UI::Button.new) { "Continue" } %>
<% end %>
This panel anchors to the bottom edge with a capped height.
<% end %>
<% end %>
```
## Sidebar (`UI::Sidebar`)
A collapsible application sidebar with groups and menus.
Add it: `bin/rails generate velvetkit:add sidebar`
Also adds: `icon`, `spinner`, `button`
Stimulus: `ui--sidebar`
### Arguments
```ruby
UI::Sidebar.new(collapsible: :none, variant: :sidebar, side: :left, state: :expanded, **system_args)
```
Slots: `with_sidebar_header`, `with_sidebar_content`, `with_sidebar_footer`, `with_sidebar_group`, `with_label`, `with_menu`, `with_menu_item`, `with_menu_button`
### Example: Default
```erb
<% brand = render("ui/sidebar_preview/brand") %>
<% main = capture do %>
Main content area next to the sidebar.
<% end %>
<%= render "ui/sidebar_preview/shell",
sidebar_id: "preview-sidebar-default",
brand: brand,
main: main,
groups: [
{ label: "Applications", items: [
{ icon: "layout-dashboard", text: "Dashboard", active: true, icon_class: "stroke-orange-500" },
{ icon: "users", text: "Team", icon_class: "stroke-purple-500" },
{ icon: "file-text", text: "Reports" }
] }
] %>
```
### Example: With Groups
```erb
<% brand = render("ui/sidebar_preview/brand") %>
<% main = capture do %>
Two labelled groups sharing one sidebar.
<% end %>
<%= render "ui/sidebar_preview/shell",
sidebar_id: "preview-sidebar-groups",
brand: brand,
main: main,
groups: [
{ label: "Navigation", items: [
{ icon: "home", text: "Home", active: true },
{ icon: "layout-dashboard", text: "Dashboard" },
{ icon: "mail", text: "Messages" }
] },
{ label: "Settings", items: [
{ icon: "settings", text: "General" },
{ icon: "shield", text: "Security" },
{ icon: "bell", text: "Notifications" }
] }
] %>
```
### Example: With Dropdown Header
```erb
<% brand = capture do %>
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger(variant: :ghost, class: "w-full justify-start px-2 h-auto py-1.5 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0") do %>
VelvetKit
Workspace
<%= render(UI::Icon.new(name: "chevrons-up-down", size: :sm, class: "ml-auto group-data-[collapsible=icon]:hidden")) %>
<% end %>
<% menu.with_content(side: :right, align: :start, class: "w-56") do |content| %>
<% content.with_label("Workspaces") %>
<% content.with_item("VelvetKit") %>
<% content.with_item("Marketing site") %>
<% content.with_item("Internal tools") %>
<% end %>
<% end %>
<% end %>
<% main = capture do %>
Sidebar with a dropdown menu inside the header for context switching.
<% end %>
<%= render "ui/sidebar_preview/shell",
sidebar_id: "preview-sidebar-dropdown",
brand: brand,
main: main,
groups: [
{ label: "Applications", items: [
{ icon: "layout-dashboard", text: "Dashboard", active: true, icon_class: "stroke-orange-500" },
{ icon: "users", text: "Team", icon_class: "stroke-purple-500" }
] }
] %>
```
### Example: Collapsible
```erb
<% brand = render("ui/sidebar_preview/brand") %>
<% main = capture do %>
Collapsible Sidebar
Click the toggle button to collapse the sidebar to icon-only mode. State is persisted in localStorage.
<%= render(UI::Tabs.new(default_value: "overview")) do |tabs| %>
<% tabs.with_list do |list| %>
<% list.with_trigger(value: "overview") { "Overview" } %>
<% list.with_trigger(value: "analytics") { "Analytics" } %>
<% list.with_trigger(value: "reports") { "Reports" } %>
<% end %>
<% tabs.with_panel(value: "overview") do %>
Project overview with key metrics and recent activity.
<%= render(UI::Menu.new) do |menu| %>
<% menu.with_trigger(variant: :outline, size: :sm) { "Actions" } %>
<% menu.with_content do |content| %>
<% content.with_label("Options") %>
<% content.with_item("Edit project") %>
<% content.with_item("Share") %>
<% content.with_divider %>
<% content.with_item("Delete", variant: :destructive) %>
<% end %>
<% end %>
<% end %>
<% tabs.with_panel(value: "analytics") do %>
Traffic and conversion analytics over the last 30 days.
<% end %>
<% tabs.with_panel(value: "reports") do %>
Generated reports and scheduled exports.
<% end %>
<% end %>
<% end %>
<%= render "ui/sidebar_preview/shell",
sidebar_id: "preview-sidebar-collapsible",
collapsible: :icon,
brand: brand,
main: main,
groups: [
{ label: "Platform", items: [
{ icon: "layout-dashboard", text: "Dashboard", active: true },
{ icon: "bar-chart-3", text: "Analytics" },
{ icon: "folder-kanban", text: "Projects" }
] },
{ label: "Account", items: [
{ icon: "user", text: "Profile" },
{ icon: "credit-card", text: "Billing" }
] }
] %>
```
## Spinner (`UI::Spinner`)
A loading indicator.
Add it: `bin/rails generate velvetkit:add spinner`
Also adds: `icon`
### Arguments
```ruby
UI::Spinner.new(size: :default, label: nil, class: nil, **html_attrs)
```
### Example: All Sizes
```erb
<%= render(UI::Spinner.new(size: :xs)) %>
<%= render(UI::Spinner.new(size: :sm)) %>
<%= render(UI::Spinner.new(size: :default)) %>
<%= render(UI::Spinner.new(size: :lg)) %>
<%= render(UI::Spinner.new(size: :xl)) %>
```
### Example: In Buttons
```erb
<%= render(UI::Button.new(loading: true, size: :sm)) { "Saving" } %>
<%= render(UI::Button.new(loading: true)) { "Saving" } %>
<%= render(UI::Button.new(loading: true, size: :lg)) { "Saving" } %>
<%= render(UI::Button.new(loading: true, variant: :destructive)) { "Deleting" } %>
<%= render(UI::Button.new(loading: true, variant: :outline)) { "Loading" } %>
```
## Switch (`UI::Switch`)
An on/off toggle.
Add it: `bin/rails generate velvetkit:add switch`
### Arguments
```ruby
UI::Switch.new(name: nil, value: "1", checked: false, label: nil, size: :default, required: false, disabled: false, class: nil, **html_attrs)
```
### Example: Default
```erb
<%= render(UI::Switch.new(name: "default_off", label: "Airplane mode")) %>
<%= render(UI::Switch.new(name: "default_on", checked: true, label: "Wi-Fi")) %>
```
### Example: Sizes
```erb
<%= render(UI::Switch.new(name: "sm", size: :sm, label: "Small")) %>
<%= render(UI::Switch.new(name: "default", size: :default, label: "Default")) %>
<%= render(UI::Switch.new(name: "lg", size: :lg, label: "Large")) %>
```
### Example: States
```erb
<%= render(UI::Switch.new(name: "unchecked", label: "Off")) %>
<%= render(UI::Switch.new(name: "checked", checked: true, label: "On")) %>
<%= render(UI::Switch.new(name: "disabled_off", disabled: true, label: "Disabled off")) %>
<%= render(UI::Switch.new(name: "disabled_on", checked: true, disabled: true, label: "Disabled on")) %>
<%= render(UI::Switch.new(name: "no_label")) %>
```
## Table (`UI::Table`)
A styled table with header, rows and caption.
Add it: `bin/rails generate velvetkit:add table`
### Arguments
```ruby
UI::Table.new(caption: nil, class: nil, **html_attrs)
```
Slots: `with_head`, `with_row`, `with_empty_state`, `with_column`, `with_cell`
### Notes
UI::Table — a data table.
Lists get long and phones are narrow, so the table is always wrapped in a
horizontal scroll container rather than allowed to break the page layout.
Usage:
<%= render(UI::Table.new(caption: "Invoices")) do |table| %>
<% table.with_head do |head| %>
<% head.with_column { "Customer" } %>
<% head.with_column(align: :end) { "Amount" } %>
<% end %>
<% @invoices.each do |invoice| %>
<% table.with_row do |row| %>
<% row.with_cell { invoice.customer.name } %>
<% row.with_cell(align: :end) { number_to_currency(invoice.amount) } %>
<% end %>
<% end %>
<% table.with_empty_state { "No invoices yet" } %>
<% end %>
### Example: Customers
```erb
<%= render(UI::Table.new) do |table| %>
<% table.with_head do |head| %>
<% head.with_column { "Customer" } %>
<% head.with_column { "Plan" } %>
<% head.with_column(align: :end) { "Seats" } %>
<% end %>
<% [ [ "Aditi Rao", "Business", "24" ], [ "Arjun Nair", "Team", "8" ],
[ "Chitra Menon", "Free", "1" ] ].each do |name, plan, seats| %>
<% table.with_row do |row| %>
<% row.with_cell { name } %>
<% row.with_cell { plan } %>
<% row.with_cell(align: :end) { seats } %>
<% end %>
<% end %>
<% end %>
```
### Example: Empty
```erb
<%# An empty table should say so rather than show a bare header. %>
<%= render(UI::Table.new) do |table| %>
<% table.with_head do |head| %>
<% head.with_column { "Customer" } %>
<% head.with_column(align: :end) { "Amount" } %>
<% end %>
<% table.with_empty_state { "No invoices yet" } %>
<% end %>
```
### Example: With Caption And Selection
```erb
<%= render(UI::Table.new(caption: "Invoices this month — 2 open")) do |table| %>
<% table.with_head do |head| %>
<% head.with_column { "Invoice" } %>
<% head.with_column(align: :end) { "Amount" } %>
<% end %>
<% table.with_row(selected: true) do |row| %>
<% row.with_cell { "INV-104" } %>
<% row.with_cell(align: :end) { "₹12,400" } %>
<% end %>
<% table.with_row do |row| %>
<% row.with_cell { "INV-105" } %>
<% row.with_cell(align: :end) { "₹8,250" } %>
<% end %>
<% end %>
```
## Table of contents (`UI::TableOfContents`)
An "On this page" list that follows the reader's scroll.
Add it: `bin/rails generate velvetkit:add table_of_contents`
Stimulus: `ui--table-of-contents`
### Arguments
```ruby
UI::TableOfContents.new(title: nil, label: nil, offset: 80, container: nil, class: nil, **html_attrs)
```
Slots: `with_item`
### Notes
"On this page" navigation with scrollspy. As the reader scrolls, the link to
the section they are reading is marked current (aria-current="location") and
an indicator slides beside it. A link jumps to its section like any anchor.
Each item links to an element id on the page, and items nest:
<%= render(UI::TableOfContents.new(title: "On this page")) do |toc| %>
<% toc.with_item(href: "#install", text: "Install") do |item| %>
<% item.with_item(href: "#requirements", text: "Requirements") %>
<% end %>
<% toc.with_item(href: "#usage", text: "Usage") %>
<% end %>
A section counts as reached once its top passes `offset:` pixels below the
top of the scroll area; raise it to clear a sticky header. When the content
scrolls inside an element rather than the window, pass that element's CSS
selector as `container:`.
### Example: Nested sections
```erb
<%# The article scrolls in its own box here; on a real page leave out `container:`. %>
Contribution process
Every change goes through the same four steps.
<% [
["toc-default-issue", "1. Create an issue or open a merge request"],
["toc-default-classify", "2. Classify as core or extended"],
["toc-default-support", "3. Define level of support"],
["toc-default-merge", "4. Merge your changes"]
].each do |id, heading| %>
<%= heading %>
Describe the change and why it matters. Link related issues, add screenshots for visual changes, and note anything reviewers should try by hand.
Keep each change focused on one thing, so it is quick to review and easy to revert.
<% end %>
Resources
Guides, design files and the component checklist.
Ask in the discussion board when something is unclear.
Code of conduct
Be kind, assume good intent, and keep feedback about the work.
<%= render(UI::TableOfContents.new(title: "On this page", container: "#toc-default-article", offset: 24)) do |toc| %>
<% toc.with_item(href: "#toc-default-process", text: "Contribution process") do |item| %>
<% item.with_item(href: "#toc-default-issue", text: "1. Create an issue or open a merge request") %>
<% item.with_item(href: "#toc-default-classify", text: "2. Classify as core or extended") %>
<% item.with_item(href: "#toc-default-support", text: "3. Define level of support") %>
<% item.with_item(href: "#toc-default-merge", text: "4. Merge your changes") %>
<% end %>
<% toc.with_item(href: "#toc-default-resources", text: "Resources") %>
<% toc.with_item(href: "#toc-default-conduct", text: "Code of conduct") %>
<% end %>
```
### Example: Flat
```erb
<% %w[Install Configure Theme Deploy].each do |heading| %>
<%= heading %>
Each section starts with what you need and ends with how to check it worked.
Commands run from the root of your Rails app.
<% end %>
<%= render(UI::TableOfContents.new(container: "#toc-flat-article", offset: 24)) do |toc| %>
<% %w[Install Configure Theme Deploy].each do |heading| %>
<% toc.with_item(href: "#toc-flat-#{heading.downcase}", text: heading) %>
<% end %>
<% end %>
```
## Tabs (`UI::Tabs`)
Tabbed panels with keyboard navigation.
Add it: `bin/rails generate velvetkit:add tabs`
Also adds: `icon`
Stimulus: `ui--tabs`
### Arguments
```ruby
UI::Tabs.new(default_value:, variant: :line, size: :default, class: nil, **html_attrs)
```
Slots: `with_panel`, `with_trigger`, `with_leading_icon`
### Example: Default (Line)
```erb
<%= render(UI::Tabs.new(default_value: "account")) do |tabs| %>
<% tabs.with_list do |list| %>
<% list.with_trigger(value: "account") { "Account" } %>
<% list.with_trigger(value: "password") { "Password" } %>
<% list.with_trigger(value: "team") { "Team" } %>
<% list.with_trigger(value: "billing", disabled: true) { "Billing" } %>
<% end %>
<% tabs.with_panel(value: "account") do %>
Manage your account settings and preferences.
Account content goes here.
<% end %>
<% tabs.with_panel(value: "password") do %>
Update your password and security settings.
Password content goes here.
<% end %>
<% tabs.with_panel(value: "team") do %>
Manage team members and permissions.
<% end %>
<% tabs.with_panel(value: "billing") do %>
View and manage billing information.
<% end %>
<% end %>
```
### Example: Pill Variant
```erb
<%= render(UI::Tabs.new(variant: :pill, default_value: "general")) do |tabs| %>
<% tabs.with_list do |list| %>
<% list.with_trigger(value: "general") { "General" } %>
<% list.with_trigger(value: "security") { "Security" } %>
<% list.with_trigger(value: "notifications") { "Notifications" } %>
<% end %>
<% tabs.with_panel(value: "general") do %>
General settings for your application.
<% end %>
<% tabs.with_panel(value: "security") do %>
Security and privacy settings.
<% end %>
<% tabs.with_panel(value: "notifications") do %>
Notification preferences.
<% end %>
<% end %>
```
### Example: With Icons
```erb
<%= render(UI::Tabs.new(default_value: "profile")) do |tabs| %>
<% tabs.with_list do |list| %>
<% list.with_trigger(value: "profile") do |trigger| %>
<% trigger.with_leading_icon(name: "user") %>
Profile
<% end %>
<% list.with_trigger(value: "security") do |trigger| %>
<% trigger.with_leading_icon(name: "lock") %>
Security
<% end %>
<% list.with_trigger(value: "settings") do |trigger| %>
<% trigger.with_leading_icon(name: "panel-left") %>
Settings
<% end %>
<% list.with_trigger(value: "verified", disabled: true) do |trigger| %>
<% trigger.with_leading_icon(name: "badge-check") %>
Verified
<% end %>
<% end %>
<% tabs.with_panel(value: "profile") do %>
Your profile information.
<% end %>
<% tabs.with_panel(value: "security") do %>
Security settings.
<% end %>
<% tabs.with_panel(value: "settings") do %>
Application settings.
<% end %>
<% tabs.with_panel(value: "verified") do %>
Verification status.
<% end %>
<% end %>
```
### Example: Settings Form
```erb
Settings
<%= render(UI::Tabs.new(variant: :line, default_value: "account")) do |tabs| %>
<% tabs.with_list do |list| %>
<% list.with_trigger(value: "account") { "Account" } %>
<% list.with_trigger(value: "password") { "Password" } %>
<% list.with_trigger(value: "billing") { "Billing" } %>
<% end %>
<% tabs.with_panel(value: "account") do %>
<%= label_tag :name, "Name", class: "block text-sm font-medium mb-1" %>
<%= text_field_tag :name, "", class: "w-full px-3 py-2 border rounded-md text-sm" %>
<%= label_tag :email, "Email", class: "block text-sm font-medium mb-1" %>
<%= email_field_tag :email, "", class: "w-full px-3 py-2 border rounded-md text-sm" %>
<%= render(UI::Button.new) { "Save Changes" } %>
<% end %>
<% tabs.with_panel(value: "password") do %>
<%= label_tag :current, "Current Password", class: "block text-sm font-medium mb-1" %>
<%= password_field_tag :current, "", class: "w-full px-3 py-2 border rounded-md text-sm" %>
<%= label_tag :new, "New Password", class: "block text-sm font-medium mb-1" %>
<%= password_field_tag :new, "", class: "w-full px-3 py-2 border rounded-md text-sm" %>
<%= render(UI::Button.new) { "Update Password" } %>
<% end %>
<% tabs.with_panel(value: "billing") do %>
Manage your billing information and subscription.
<%= render(UI::Button.new(variant: :outline)) { "View Invoices" } %>
<% end %>
<% end %>
```
## Text (`UI::Text`)
Body text in set sizes, weights and tones.
Add it: `bin/rails generate velvetkit:add text`
### Arguments
```ruby
UI::Text.new(as: :p, size: :base, weight: :normal, tone: :default, class: nil, **html_attrs)
```
### Example: Sizes
```erb
<%= render(UI::Text.new(size: :xs)) { "Extra Small (xs) — The quick brown fox" } %>
<%= render(UI::Text.new(size: :sm)) { "Small (sm) — The quick brown fox" } %>
<%= render(UI::Text.new(size: :base)) { "Base — The quick brown fox" } %>
<%= render(UI::Text.new(size: :lg)) { "Large (lg) — The quick brown fox" } %>
<%= render(UI::Text.new(size: :xl)) { "Extra Large (xl) — The quick brown fox" } %>
<%= render(UI::Text.new(size: :"2xl")) { "2XL — The quick brown fox" } %>
<%= render(UI::Text.new(size: :"3xl")) { "3XL — The quick brown fox" } %>
<%= render(UI::Text.new(size: :"4xl")) { "4XL — The quick brown fox" } %>
```
### Example: Weights
```erb
<%= render(UI::Text.new(weight: :normal)) { "Normal weight — The quick brown fox" } %>
<%= render(UI::Text.new(weight: :medium)) { "Medium weight — The quick brown fox" } %>
<%= render(UI::Text.new(weight: :semibold)) { "Semibold weight — The quick brown fox" } %>
<%= render(UI::Text.new(weight: :bold)) { "Bold weight — The quick brown fox" } %>
```
### Example: Tones
```erb
<%= render(UI::Text.new(tone: :default)) { "Default tone — The quick brown fox" } %>
<%= render(UI::Text.new(tone: :muted)) { "Muted tone — The quick brown fox" } %>
<%= render(UI::Text.new(tone: :destructive)) { "Destructive tone — The quick brown fox" } %>
```
## Textarea (`UI::Textarea`)
A multi-line text field with label, hint and error, that can grow with its text.
Add it: `bin/rails generate velvetkit:add textarea`
Also adds: `label`, `form_field`
### Arguments
```ruby
UI::Textarea.new(size: :default, rows: 3, resize: :vertical, autogrow: false, label: nil, label_size: :default, label_weight: :medium, hint: nil, required: false, error: nil, disabled: false, value: nil, form: nil, field: nil, class: nil, **html_attrs)
```
### Notes
A multi-line text field, with the same label, hint, required, error and
form-builder options as UI::Input.
`autogrow: true` grows the box with its text (CSS field-sizing, no
JavaScript); browsers without it keep `rows:` lines and scroll. Cap the
height with a class such as `max-h-60`.
### Example: Default
```erb
<%= render(UI::Textarea.new(label: "Feedback", placeholder: "What could we do better?", rows: 4)) %>
```
### Example: Grows with its text
```erb
<%= render(UI::Textarea.new(label: "Comment", autogrow: true, class: "max-h-60",
placeholder: "Type a few lines; the box grows with them, up to a limit.")) %>
```
### Example: States
```erb
<%= render(UI::Textarea.new(label: "Bio", value: "Rails developer who likes accessible UI.")) %>
<%= render(UI::Textarea.new(label: "Reason", required: true, placeholder: "Required")) %>
<%= render(UI::Textarea.new(label: "Summary", error: true, value: "Too short")) %>
<%= render(UI::Textarea.new(label: "Notes", disabled: true, value: "Locked after review")) %>
<%= render(UI::Textarea.new(label: "Fixed size", resize: :none, placeholder: "Can't be resized")) %>
```
### Example: All Sizes
```erb
<%= render(UI::Textarea.new(size: :sm, rows: 2, placeholder: "Small")) %>
<%= render(UI::Textarea.new(rows: 2, placeholder: "Default")) %>
<%= render(UI::Textarea.new(size: :lg, rows: 2, placeholder: "Large")) %>
```
## Theme toggle (`UI::ThemeToggle`)
A light/dark/system theme switcher.
Add it: `bin/rails generate velvetkit:add theme_toggle`
Also adds: `icon`, `spinner`, `button`, `menu`
Stimulus: `ui--theme`
### Arguments
```ruby
UI::ThemeToggle.new(label: nil, class: nil, **html_attrs)
```
### Notes
UI::ThemeToggle — System / Light / Dark picker.
The preference lives in localStorage under `ui-theme` and is applied by the
`ui--theme` Stimulus controller, which sets `.dark` on . The initial
class is set pre-paint by layouts/_theme_script to avoid a flash.
Usage:
<%= render(UI::ThemeToggle.new) %>
### Example: In A Header
```erb
<%# Mirrors the real app header: sidebar trigger left, theme toggle right. %>
<%= render(UI::Sidebar::Trigger.new(sidebar_id: "preview-sidebar")) %>
<%= render(UI::ThemeToggle.new) %>
<%= render(UI::Text.new) { "Switch the theme with the control above — the whole page follows." } %>
```
### Example: Palette
```erb
<%# Every semantic token side by side. The fastest way to review both themes:
flip the toggle and check nothing goes muddy or illegible. %>
<% pairs = [
["background", "foreground"], ["card", "card-foreground"],
["popover", "popover-foreground"], ["primary", "primary-foreground"],
["secondary", "secondary-foreground"], ["muted", "muted-foreground"],
["accent", "accent-foreground"], ["sidebar", "sidebar-foreground"],
["sidebar-accent", "sidebar-accent-foreground"]
] %>
<%= render(UI::ThemeToggle.new) %>
<%= render(UI::Typography::Muted.new) { "Flip the theme, then scan for anything unreadable." } %>
<% pairs.each do |bg, fg| %>
<% end %>
<%= render(UI::Button.new) { "Primary" } %>
<%= render(UI::Button.new(variant: :secondary)) { "Secondary" } %>
<%= render(UI::Button.new(variant: :outline)) { "Outline" } %>
<%= render(UI::Button.new(variant: :ghost)) { "Ghost" } %>
<%= render(UI::Button.new(variant: :destructive)) { "Destructive" } %>
<% %i[default secondary destructive outline success warning].each do |v| %>
<%= render(UI::Badge.new(variant: v)) { v.to_s.capitalize } %>
<% end %>
<% %i[default destructive warning success info].each do |v| %>
<%= render(UI::Alert.new(variant: v)) do |alert| %>
<% alert.with_title { v.to_s.capitalize } %>
<% alert.with_description { "How the #{v} alert reads in this theme." } %>
<% end %>
<% end %>
```
## Tooltip (`UI::Tooltip`)
A hint shown on hover and focus.
Add it: `bin/rails generate velvetkit:add tooltip`
Stimulus: `ui--tooltip`
Installs: `@floating-ui/dom`
### Arguments
```ruby
UI::Tooltip.new(class: nil, **html_attrs)
```
Slots: `with_trigger`, `with_tooltip_content`, `with_content`
### Example: Default
```erb
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= tag.button("Hover me", class: "px-4 py-2 bg-primary text-primary-foreground rounded-md") %>
<% end %>
<% tooltip.with_content do %>
Add to library
<% end %>
<% end %>
```
### Example: Positions
```erb
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= tag.button("Top", class: "px-4 py-2 bg-primary text-primary-foreground rounded-md") %>
<% end %>
<% tooltip.with_content(side: :top) do %>
Tooltip on top
<% end %>
<% end %>
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= tag.button("Right", class: "px-4 py-2 bg-primary text-primary-foreground rounded-md") %>
<% end %>
<% tooltip.with_content(side: :right) do %>
Tooltip on right
<% end %>
<% end %>
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= tag.button("Bottom", class: "px-4 py-2 bg-primary text-primary-foreground rounded-md") %>
<% end %>
<% tooltip.with_content(side: :bottom) do %>
Tooltip on bottom
<% end %>
<% end %>
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= tag.button("Left", class: "px-4 py-2 bg-primary text-primary-foreground rounded-md") %>
<% end %>
<% tooltip.with_content(side: :left) do %>
Tooltip on left
<% end %>
<% end %>
```
### Example: With Button
```erb
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= tag.button("Save Changes", class: "px-4 py-2 bg-primary text-primary-foreground rounded-md") %>
<% end %>
<% tooltip.with_content do %>
Save your changes to the document
<% end %>
<% end %>
```
### Example: With Icon
```erb
<%= render(UI::Tooltip.new) do |tooltip| %>
<% tooltip.with_trigger do %>
<%= render(UI::Icon.new(name: :info, size: :sm)) %>
<% end %>
<% tooltip.with_content do %>
More information about this feature
<% end %>
<% end %>
```
## Typography (`UI::Typography::*`)
Headings, lead, muted, code and blockquote text styles.
Add it: `bin/rails generate velvetkit:add typography`
Also adds: `text`
### Example: Headings
```erb
<%= render(UI::Typography::H1.new) { "Heading 1" } %>
<%= render(UI::Typography::H2.new) { "Heading 2" } %>
<%= render(UI::Typography::H3.new) { "Heading 3" } %>
<%= render(UI::Typography::H4.new) { "Heading 4" } %>
```
### Example: Text styles
```erb
<%= render(UI::Typography::Lead.new) { "Lead: an introduction that sets up the page in a sentence or two." } %>
<%= render(UI::Typography::Large.new) { "Large: a line that needs a little more weight." } %>
<%= render(UI::Text.new) { "Body text, for most of what a page says." } %>
<%= render(UI::Typography::Muted.new) { "Muted: secondary detail, like a caption or a hint." } %>
<%= render(UI::Typography::Small.new) { "Small: a label or fine print." } %>
```
### Example: Code and quotes
```erb
<%= render(UI::Text.new) do %>
Run <%= render(UI::Typography::Code.new) { "bin/rails generate velvetkit:add dialog" } %> to add a dialog.
<% end %>
<%= render(UI::Typography::Blockquote.new) { "Good components disappear: you notice the work, not the widgets." } %>
```
### Example: An article
```erb
<%= render(UI::Typography::H2.new) { "Release notes" } %>
<%= render(UI::Typography::Lead.new) { "This month's update makes reports faster to build and easier to share." } %>
<%= render(UI::Typography::H3.new) { "What changed" } %>
<%= render(UI::Text.new) do %>
Reports now load in under a second, and you can share one with a link. Owners can still limit sharing from
<%= render(UI::Typography::Code.new) { "Settings › Sharing" } %>.
<% end %>
<%= render(UI::Typography::Blockquote.new) { "We asked for faster reports and got them the same month." } %>
<%= render(UI::Typography::Muted.new) { "Published 21 October" } %>
```